Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Example/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions Source/Database/Model/OfflineAsset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ public class OfflineAsset: Object {
public static var manager = ObjectManager<OfflineAsset>()
}

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"
Expand Down
7 changes: 6 additions & 1 deletion Source/Database/ObjectManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,17 @@ public class ObjectManager<T: Object> {
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 {
object[key] = value
}
}
}

func exists(id: Any) -> Bool {
let object = realm.object(ofType: T.self, forPrimaryKey: id)
return object != nil
}
}
100 changes: 100 additions & 0 deletions Source/Database/TPStreamsDownloadManager.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 3 additions & 0 deletions Source/Managers/TPStreamPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Source/TPStreamPlayerConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}


Expand Down Expand Up @@ -43,6 +44,11 @@ public class TPStreamPlayerConfigurationBuilder {
return self
}

public func showDownloadOption() -> Self {
configuration.showDownloadOption = true
return self
}

public func build() -> TPStreamPlayerConfiguration {
return configuration
}
Expand Down
2 changes: 1 addition & 1 deletion Source/TPStreamsSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public class TPStreamsSDK {
options.attachViewHierarchy = true
}
}

private static func initializeDatabase() {
Realm.Configuration.defaultConfiguration = Realm.Configuration(schemaVersion: 1)
}
Expand Down
30 changes: 28 additions & 2 deletions Source/Views/SwiftUI/PlayerSettingsButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()]
)
}
}

Expand All @@ -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
Expand All @@ -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 }
41 changes: 35 additions & 6 deletions Source/Views/UIKit/PlayerControlsUIView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand All @@ -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")
}
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions StoryboardExample/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class ViewController: UIViewController {
.setPreferredRewindDuration(5)
.setprogressBarThumbColor(.systemBlue)
.setwatchedProgressTrackColor(.systemBlue)
.showDownloadOption()
.build()

playerViewController?.config = config
Expand Down
33 changes: 33 additions & 0 deletions Tests/ObjectManagerTest.swift
Original file line number Diff line number Diff line change
@@ -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")
}

}
Loading