diff --git a/Example/ContentView.swift b/Example/ContentView.swift index 32fe973..60fc9bc 100644 --- a/Example/ContentView.swift +++ b/Example/ContentView.swift @@ -17,6 +17,16 @@ struct ContentView: View { TPStreamPlayerView(player: player) .frame(height: 240) Spacer() + Button(action: { + TPStreamsDownloadManager.shared.startDownloadWithPlayer(player: player) + print("Button tapped!") + }) { + Text("Download") + .padding() + .foregroundColor(.white) + .background(Color.blue) + .cornerRadius(10) + } } } } diff --git a/Source/Offline/Model/OfflineAsset.swift b/Source/Offline/Model/OfflineAsset.swift new file mode 100644 index 0000000..67127f5 --- /dev/null +++ b/Source/Offline/Model/OfflineAsset.swift @@ -0,0 +1,47 @@ +// +// OfflineAsset.swift +// TPStreamsSDK +// +// Created by Prithuvi on 03/01/24. +// + +import Foundation + +public struct OfflineAsset { + var id: String + var created_at = Date() + var title: String = "" + var srcURL: String = "" + var downloadedPath: String = "" + var downloadedAt = Date() + var status:String = Status.notStarted.rawValue + var percentageCompleted: Double = 0.0 +} + +enum Status: String { + case notStarted = "notStarted" + case inProgress = "inProgress" + case paused = "paused" + case finished = "finished" + case failed = "failed" +} + +extension OfflineAsset { + + mutating func updateDownloadPath(downloadedPath: String) { + self.downloadedPath = downloadedPath + } + + mutating func updateStatus(status: String) { + self.status = status + } + + mutating func updatePercentageCompleted(percentageCompleted: Double) { + self.percentageCompleted = percentageCompleted + } + + mutating func updateDownloadAt(downloadedAt: Date) { + self.downloadedAt = downloadedAt + } + +} diff --git a/Source/Offline/TPStreamsDatabase.swift b/Source/Offline/TPStreamsDatabase.swift new file mode 100644 index 0000000..b423a49 --- /dev/null +++ b/Source/Offline/TPStreamsDatabase.swift @@ -0,0 +1,146 @@ +// +// TPStreamsDatabase.swift +// TPStreamsSDK +// +// Created by Prithuvi on 04/01/24. +// +// This class implements a local database using SQLite.swift library (https://github.com/stephencelis/SQLite.swift). +// All operations in this class are based on the documentation of the SQLite.swift library. +// Documentation (https://github.com/stephencelis/SQLite.swift/blob/master/Documentation/Index.md) + +import Foundation +import SQLite + +internal class TPStreamsDatabase { + + private var offlineAssetDatabasePath: String? + private var offlineAssetDatabase: Connection? + private var offlineAssetTable: Table? + // Columns in Table + private let id = Expression("id") + private let created_at = Expression("created_at") + private let title = Expression("title") + private let srcURL = Expression("srcURL") + private let downloadedPath = Expression("downloadedPath") + private let downloadedAt = Expression("downloadedAt") + private let status = Expression("status") + private let percentageCompleted = Expression("percentageCompleted") + + func initialize() { + do { + offlineAssetDatabasePath = NSSearchPathForDirectoriesInDomains( + .documentDirectory, .userDomainMask, true + ).first! + + // Create Database + offlineAssetDatabase = try Connection("\(offlineAssetDatabasePath!)/db.sqlite3") + + // Initialize Table + offlineAssetTable = Table("OfflineAsset") + + // Create Table + try offlineAssetDatabase!.run(offlineAssetTable!.create { offlineAsset in + offlineAsset.column(id, primaryKey: true) + offlineAsset.column(created_at) + offlineAsset.column(title) + offlineAsset.column(srcURL, unique: true) + offlineAsset.column(downloadedPath, unique: true) + offlineAsset.column(downloadedAt) + offlineAsset.column(status) + offlineAsset.column(percentageCompleted) + }) + } catch { + print (error) + } + } + + func insert( _ offlineAssets: OfflineAsset) { + do { + try offlineAssetDatabase!.run( + offlineAssetTable!.insert( + id <- offlineAssets.id, + created_at <- offlineAssets.created_at, + title <- offlineAssets.title, + srcURL <- offlineAssets.srcURL, + downloadedPath <- offlineAssets.downloadedPath, + downloadedAt <- offlineAssets.downloadedAt, + status <- offlineAssets.status, + percentageCompleted <- offlineAssets.percentageCompleted + ) + ) + } catch { + print("insertion failed: \(error)") + } + } + + func update( _ offlineAssets: OfflineAsset) { + let tempOfflineAsset = offlineAssetTable!.filter(id == offlineAssets.id) + do { + try offlineAssetDatabase!.run( + tempOfflineAsset.update( + downloadedPath <- offlineAssets.downloadedPath, + downloadedAt <- offlineAssets.downloadedAt, + status <- offlineAssets.status, + percentageCompleted <- offlineAssets.percentageCompleted + ) + ) + } catch { + print("updation failed: \(error)") + } + } + + func get(id: String) -> OfflineAsset? { + do { + let query = offlineAssetTable!.filter(self.id == id) + if let offlineAsset = try offlineAssetDatabase!.pluck(query) { + let result = OfflineAsset( + id: offlineAsset[self.id], + created_at: offlineAsset[self.created_at], + title: offlineAsset[self.title], + srcURL: offlineAsset[self.srcURL], + downloadedPath: offlineAsset[self.downloadedPath], + downloadedAt: offlineAsset[self.downloadedAt], + status: offlineAsset[self.status], + percentageCompleted: offlineAsset[self.percentageCompleted] + ) + return result + } + } catch { + print("Error fetching offline asset with id \(id): \(error)") + } + return nil + } + + func get(srcURL: String) -> OfflineAsset? { + do { + let query = offlineAssetTable!.filter(self.srcURL == srcURL) + if let offlineAsset = try offlineAssetDatabase!.pluck(query) { + let result = OfflineAsset( + id: offlineAsset[self.id], + created_at: offlineAsset[self.created_at], + title: offlineAsset[self.title], + srcURL: offlineAsset[self.srcURL], + downloadedPath: offlineAsset[self.downloadedPath], + downloadedAt: offlineAsset[self.downloadedAt], + status: offlineAsset[self.status], + percentageCompleted: offlineAsset[self.percentageCompleted] + ) + return result + } + } catch { + print("Error fetching offline asset with srcURL \(id): \(error)") + } + return nil + } + + func delete(id: String) { + do { + let tempOfflineAsset = offlineAssetTable!.filter(self.id == id) + try offlineAssetDatabase!.run(tempOfflineAsset.delete()) + } catch { + print("deletion failed: \(error)") + } + } + + +} diff --git a/Source/Offline/TPStreamsDownloadManager.swift b/Source/Offline/TPStreamsDownloadManager.swift new file mode 100644 index 0000000..78e0800 --- /dev/null +++ b/Source/Offline/TPStreamsDownloadManager.swift @@ -0,0 +1,92 @@ +// +// TPStreamsDownloadManager.swift +// TPStreamsSDK +// +// Created by Prithuvi on 03/01/24. +// + +import Foundation +import AVFoundation + + +public final class TPStreamsDownloadManager: NSObject { + + static public let shared = TPStreamsDownloadManager() + private var assetDownloadURLSession: AVAssetDownloadURLSession! + private var activeDownloadsMap = [AVAssetDownloadTask: OfflineAsset]() + private var tpStreamsDatabase: TPStreamsDatabase? + private var player: TPAVPlayer? + + private override init() { + super.init() + tpStreamsDatabase = TPStreamsDatabase() + tpStreamsDatabase?.initialize() + let backgroundConfiguration = URLSessionConfiguration.background(withIdentifier: "com.tpstreams.downloadSession") + assetDownloadURLSession = AVAssetDownloadURLSession( + configuration: backgroundConfiguration, + assetDownloadDelegate: self, + delegateQueue: OperationQueue.main + ) + } + + public func startDownloadWithPlayer(player: TPAVPlayer) { + startDownload(asset: player.asset!, bitRate: 100_000) + } + + internal func startDownload(asset: Asset, bitRate: Int) { + let avUrlAsset = AVURLAsset(url: URL(string: asset.video.playbackURL)!) + + guard let task = assetDownloadURLSession.makeAssetDownloadTask( + asset: avUrlAsset, + assetTitle: asset.title, + assetArtworkData: nil, + options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: bitRate] + ) else { + return + } + + let offlineAsset = OfflineAsset(id: asset.id, title: asset.title, srcURL: asset.video.playbackURL) + tpStreamsDatabase?.insert(offlineAsset) + activeDownloadsMap[task] = offlineAsset + task.resume() + } + +} + +extension TPStreamsDownloadManager: AVAssetDownloadDelegate { + + public func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { + guard var offlineAsset = activeDownloadsMap[assetDownloadTask] else { return } + offlineAsset.updateDownloadPath(downloadedPath: location.relativePath) + activeDownloadsMap[assetDownloadTask] = offlineAsset + tpStreamsDatabase?.update(offlineAsset) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let assetDownloadTask = task as? AVAssetDownloadTask, + var offlineAsset = activeDownloadsMap[assetDownloadTask] else { return } + + if let error = error { + offlineAsset.updateStatus(status: Status.failed.rawValue) + } else { + offlineAsset.updateStatus(status: Status.finished.rawValue) + } + + offlineAsset.updateDownloadAt(downloadedAt: Date()) + tpStreamsDatabase?.update(offlineAsset) + activeDownloadsMap.removeValue(forKey: assetDownloadTask) + } + + public func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { + guard var offlineAsset = activeDownloadsMap[assetDownloadTask] else { return } + var percentageComplete = 0.0 + for value in loadedTimeRanges { + let loadedTimeRange = value.timeRangeValue + percentageComplete += loadedTimeRange.duration.seconds / timeRangeExpectedToLoad.duration.seconds + } + offlineAsset.updatePercentageCompleted(percentageCompleted: percentageComplete * 100) + offlineAsset.updateStatus(status: Status.inProgress.rawValue) + activeDownloadsMap[assetDownloadTask] = offlineAsset + tpStreamsDatabase?.update(offlineAsset) + } +} diff --git a/Source/TPAVPlayer.swift b/Source/TPAVPlayer.swift index 3520dbd..759d15b 100644 --- a/Source/TPAVPlayer.swift +++ b/Source/TPAVPlayer.swift @@ -23,6 +23,7 @@ public class TPAVPlayer: AVPlayer { private var resourceLoaderDelegate: ResourceLoaderDelegate public var onError: ((Error) -> Void)? internal var initializationError: Error? + internal var asset: Asset? = nil public var availableVideoQualities: [VideoQuality] = [VideoQuality(resolution:"Auto", bitrate: 0)] @@ -49,6 +50,7 @@ public class TPAVPlayer: AVPlayer { if let asset = asset { self.setup(withAsset: asset) + self.asset = asset self.setupCompletion?(nil) } else if let error = error{ SentrySDK.capture(error: error) diff --git a/iOSPlayerSDK.xcodeproj/project.pbxproj b/iOSPlayerSDK.xcodeproj/project.pbxproj index d4fc3fb..4b735a1 100644 --- a/iOSPlayerSDK.xcodeproj/project.pbxproj +++ b/iOSPlayerSDK.xcodeproj/project.pbxproj @@ -56,6 +56,10 @@ 8EDE99AF2A2643B000E43EA9 /* TPStreamsSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8EDE99A42A2643B000E43EA9 /* TPStreamsSDK.framework */; }; 8EDE99B42A2643B000E43EA9 /* iOSPlayerSDKTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EDE99B32A2643B000E43EA9 /* iOSPlayerSDKTests.swift */; }; 8EDE99B52A2643B000E43EA9 /* iOSPlayerSDK.h in Headers */ = {isa = PBXBuildFile; fileRef = 8EDE99A72A2643B000E43EA9 /* iOSPlayerSDK.h */; settings = {ATTRIBUTES = (Public, ); }; }; + D95265422B452C9B001D6084 /* OfflineAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = D95265412B452C9B001D6084 /* OfflineAsset.swift */; }; + D95265462B452CF7001D6084 /* TPStreamsDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D95265452B452CF7001D6084 /* TPStreamsDownloadManager.swift */; }; + D9C119192B46E6C800C0C292 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = D9C119182B46E6C800C0C292 /* SQLite */; }; + D9C1191B2B46EE1100C0C292 /* TPStreamsDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1191A2B46EE1100C0C292 /* TPStreamsDatabase.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -164,6 +168,9 @@ 8EDE99AE2A2643B000E43EA9 /* iOSPlayerSDKTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iOSPlayerSDKTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8EDE99B32A2643B000E43EA9 /* iOSPlayerSDKTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSPlayerSDKTests.swift; sourceTree = ""; }; D904B1DD2B3C5BAC00A7E26C /* TPStreamsSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TPStreamsSDK.xctestplan; sourceTree = ""; }; + D95265412B452C9B001D6084 /* OfflineAsset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineAsset.swift; sourceTree = ""; }; + D95265452B452CF7001D6084 /* TPStreamsDownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TPStreamsDownloadManager.swift; sourceTree = ""; }; + D9C1191A2B46EE1100C0C292 /* TPStreamsDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TPStreamsDatabase.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -187,6 +194,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D9C119192B46E6C800C0C292 /* SQLite in Frameworks */, 0326BADE2A348690009ABC58 /* M3U8Parser in Frameworks */, 8E6389E52A275B2A00306FA4 /* Alamofire in Frameworks */, 0326BADB2A335911009ABC58 /* Sentry in Frameworks */, @@ -360,6 +368,7 @@ 8EDE99A62A2643B000E43EA9 /* Source */ = { isa = PBXGroup; children = ( + D952653F2B452C76001D6084 /* Offline */, 03CE74FE2A79469400B84304 /* Utils */, 0321F3252A2E0D0300E08AEE /* Extensions */, 8E6389E72A278D0400306FA4 /* Managers */, @@ -385,6 +394,24 @@ path = Tests; sourceTree = ""; }; + D952653F2B452C76001D6084 /* Offline */ = { + isa = PBXGroup; + children = ( + D95265402B452C80001D6084 /* Model */, + D95265452B452CF7001D6084 /* TPStreamsDownloadManager.swift */, + D9C1191A2B46EE1100C0C292 /* TPStreamsDatabase.swift */, + ); + path = Offline; + sourceTree = ""; + }; + D95265402B452C80001D6084 /* Model */ = { + isa = PBXGroup; + children = ( + D95265412B452C9B001D6084 /* OfflineAsset.swift */, + ); + path = Model; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -456,6 +483,7 @@ 8E6389E42A275B2A00306FA4 /* Alamofire */, 0326BADA2A335911009ABC58 /* Sentry */, 0326BADD2A348690009ABC58 /* M3U8Parser */, + D9C119182B46E6C800C0C292 /* SQLite */, ); productName = iOSPlayerSDK; productReference = 8EDE99A42A2643B000E43EA9 /* TPStreamsSDK.framework */; @@ -517,6 +545,7 @@ 8E6389E32A275B2A00306FA4 /* XCRemoteSwiftPackageReference "Alamofire" */, 0326BAD92A335911009ABC58 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, 0326BADC2A348690009ABC58 /* XCRemoteSwiftPackageReference "M3U8Parser" */, + D9C119172B46E6C800C0C292 /* XCRemoteSwiftPackageReference "SQLite.swift" */, ); productRefGroup = 8EDE99A52A2643B000E43EA9 /* Products */; projectDirPath = ""; @@ -596,10 +625,12 @@ 03B8FDAE2A69752800DAB7AE /* PlayerControlsUIView.swift in Sources */, 8E6389E92A278D1D00306FA4 /* ContentKeyDelegate.swift in Sources */, 8E6389DD2A27338F00306FA4 /* AVPlayerBridge.swift in Sources */, + D95265462B452CF7001D6084 /* TPStreamsDownloadManager.swift in Sources */, 8E6389BC2A2724D000306FA4 /* TPStreamPlayerView.swift in Sources */, 035351A02A2EDAFA001E38F3 /* PlayerSettingsButton.swift in Sources */, 03913C482A850BF9002E7E0C /* ProgressBar.swift in Sources */, 8E6C5CB52A28BD9A003EC948 /* TPStreamsSDK.swift in Sources */, + D95265422B452C9B001D6084 /* OfflineAsset.swift in Sources */, 0377C4132A2B272C00F7E58F /* TestpressAPI.swift in Sources */, 0377C4112A2B1F0700F7E58F /* Asset.swift in Sources */, 8E6389E22A275AA800306FA4 /* StreamsAPI.swift in Sources */, @@ -613,6 +644,7 @@ 035351A22A2F49E3001E38F3 /* MediaControlsView.swift in Sources */, 03CC86682AE142FF002F5D28 /* ResourceLoaderDelegate.swift in Sources */, 031099F62B28B22C0034D370 /* TPStreamPlayerError.swift in Sources */, + D9C1191B2B46EE1100C0C292 /* TPStreamsDatabase.swift in Sources */, 03B8090C2A2DF9A200AB3D03 /* PlayerControlsView.swift in Sources */, 8E6389DF2A2751C800306FA4 /* TPAVPlayer.swift in Sources */, 03B8090A2A2DF8A000AB3D03 /* TPStreamPlayer.swift in Sources */, @@ -1090,6 +1122,14 @@ minimumVersion = 5.0.0; }; }; + D9C119172B46E6C800C0C292 /* XCRemoteSwiftPackageReference "SQLite.swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/stephencelis/SQLite.swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.14.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1108,6 +1148,11 @@ package = 8E6389E32A275B2A00306FA4 /* XCRemoteSwiftPackageReference "Alamofire" */; productName = Alamofire; }; + D9C119182B46E6C800C0C292 /* SQLite */ = { + isa = XCSwiftPackageProductDependency; + package = D9C119172B46E6C800C0C292 /* XCRemoteSwiftPackageReference "SQLite.swift" */; + productName = SQLite; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 8EDE999B2A2643B000E43EA9 /* Project object */;