diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift index 802dbab8daa..89ca1d62737 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift @@ -178,7 +178,7 @@ extension PlaylistListViewController: UITableViewDataSource { header.onAddPlaylist = { [unowned self] in guard let sharedFolderUrl = folder.sharedFolderUrl else { return } - if PlayListDownloadType(rawValue: Preferences.Playlist.autoDownloadVideo.value) != nil { + if PlayListDownloadType(rawValue: Preferences.Playlist.autoDownloadVideo.value) != .off { let controller = PopupViewController(rootView: PlaylistFolderSharingManagementView(onAddToPlaylistPressed: { [unowned self] in self.dismiss(animated: true) diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController.swift index 876c2b85d6e..794c15548df 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController.swift @@ -637,7 +637,7 @@ extension PlaylistListViewController { return } - playerView.stop() + playerView.pause() playerView.bringSubviewToFront(activityIndicator) activityIndicator.startAnimating() activityIndicator.isHidden = false diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift index 400a7cc7241..3f33415fb24 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift @@ -581,6 +581,7 @@ extension PlaylistViewController: PlaylistViewControllerDelegate { stop(playerView) // Cancel all loading. + PlaylistManager.shared.playbackTask?.cancel() PlaylistManager.shared.playbackTask = nil } @@ -864,7 +865,12 @@ extension PlaylistViewController: VideoViewDelegate { } func load(_ videoView: VideoView, asset: AVURLAsset, autoPlayEnabled: Bool) async throws /*`MediaPlaybackError`*/ { - self.clear() + // Task will be nil if the playback has stopped, but not paused + // If it is paused, and we're loading another track, don't bother clearing the player + // as this will break PIP + if PlaylistManager.shared.playbackTask == nil { + self.clear() + } let isNewItem = try await player.load(asset: asset) diff --git a/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift b/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift index 0fd90896ccb..54aa844419c 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift @@ -108,12 +108,11 @@ public class PlaylistCarplayManager: NSObject { func getPlaylistController(tab: Tab?, initialItem: PlaylistInfo?, initialItemPlaybackOffset: Double) -> PlaylistViewController { - // If background playback is enabled, tabs will continue to play media + // If background playback is enabled (on iPhone), tabs will continue to play media // Even if another controller is presented and even when PIP is enabled in playlist. // Therefore we need to stop the page/tab from playing when using playlist. - if Preferences.General.mediaAutoBackgrounding.value { - tab?.stopMediaPlayback() - } + // On iPad, media will continue to play with or without the background play setting. + tab?.stopMediaPlayback() // If there is no media player, create one, // pass it to the play-list controller diff --git a/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/PlaylistScriptHandler.swift b/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/PlaylistScriptHandler.swift index a3b95a40444..fdc05c24aa0 100644 --- a/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/PlaylistScriptHandler.swift +++ b/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/ScriptHandlers/Paged/PlaylistScriptHandler.swift @@ -145,7 +145,7 @@ class PlaylistScriptHandler: NSObject, TabContentScript { Self.queue.async { [weak handler] in guard let handler = handler else { return } - if item.duration <= 0.0 && !item.detected || item.src.isEmpty || item.src.hasPrefix("data:") { + if item.duration <= 0.0 && !item.detected || item.src.isEmpty { DispatchQueue.main.async { handler.delegate?.updatePlaylistURLBar(tab: handler.tab, state: .none, item: nil) } diff --git a/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Paged/PlaylistScript.js b/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Paged/PlaylistScript.js index d172dc9422f..ae429ce9a31 100644 --- a/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Paged/PlaylistScript.js +++ b/Sources/Brave/Frontend/UserContent/UserScripts/Scripts_Dynamic/Scripts/Paged/PlaylistScript.js @@ -164,6 +164,14 @@ window.__firefox__.includeOnce("Playlist", function($) { style.visibility !== 'hidden'; } + function getAllVideoElements() { + return [...document.querySelectorAll('video')].reverse(); + } + + function getAllAudioElements() { + return [...document.querySelectorAll('audio')].reverse(); + } + function setupLongPress() { Object.defineProperty(window.__firefox__, '$', { enumerable: false, @@ -220,14 +228,6 @@ window.__firefox__.includeOnce("Playlist", function($) { // MARK: --------------------------------------- function setupDetector() { - function getAllVideoElements() { - return [...document.querySelectorAll('video')].reverse(); - } - - function getAllAudioElements() { - return [...document.querySelectorAll('audio')].reverse(); - } - function requestWhenIdleShim(fn) { var start = Date.now() return setTimeout(function () { diff --git a/Sources/Playlist/PlaylistDownloadManager.swift b/Sources/Playlist/PlaylistDownloadManager.swift index 67b922a5866..be87ce74900 100644 --- a/Sources/Playlist/PlaylistDownloadManager.swift +++ b/Sources/Playlist/PlaylistDownloadManager.swift @@ -35,10 +35,13 @@ public enum PlaylistDownloadError: Error { public class PlaylistDownloadManager: PlaylistStreamDownloadManagerDelegate { private let hlsSession: AVAssetDownloadURLSession private let fileSession: URLSession + private let dataSession: URLSession private let hlsDelegate = PlaylistHLSDownloadManager() private let fileDelegate = PlaylistFileDownloadManager() + private let dataDelegate = PlaylistDataDownloadManager() private let hlsQueue = OperationQueue.main private let fileQueue = OperationQueue.main + private let dataQueue = OperationQueue.main private var didRestoreSession = false weak var delegate: PlaylistDownloadManagerDelegate? @@ -68,9 +71,16 @@ public class PlaylistDownloadManager: PlaylistStreamDownloadManagerDelegate { configuration: fileConfiguration, delegate: fileDelegate, delegateQueue: fileQueue) + + let dataConfiguration = URLSessionConfiguration.background(withIdentifier: "com.brave.playlist.data.background.session") + dataSession = URLSession( + configuration: dataConfiguration, + delegate: dataDelegate, + delegateQueue: dataQueue) hlsDelegate.delegate = self fileDelegate.delegate = self + dataDelegate.delegate = self } func restoreSession(_ completion: @escaping () -> Void) { @@ -92,6 +102,11 @@ public class PlaylistDownloadManager: PlaylistStreamDownloadManagerDelegate { fileDelegate.restoreSession(fileSession) { group.leave() } + + group.enter() + dataDelegate.restoreSession(dataSession) { + group.leave() + } group.notify(queue: .main) { completion() @@ -119,11 +134,23 @@ public class PlaylistDownloadManager: PlaylistStreamDownloadManagerDelegate { } } } + + func downloadDataAsset(_ assetUrl: URL, for item: PlaylistInfo) { + if Thread.current.isMainThread { + dataDelegate.downloadAsset(self.fileSession, assetUrl: assetUrl, for: item) + } else { + fileQueue.addOperation { [weak self] in + guard let self = self else { return } + self.dataDelegate.downloadAsset(self.fileSession, assetUrl: assetUrl, for: item) + } + } + } func cancelDownload(itemId: String) { if Thread.current.isMainThread { hlsDelegate.cancelDownload(itemId: itemId) fileDelegate.cancelDownload(itemId: itemId) + dataDelegate.cancelDownload(itemId: itemId) } else { hlsQueue.addOperation { [weak self] in self?.hlsDelegate.cancelDownload(itemId: itemId) @@ -132,12 +159,16 @@ public class PlaylistDownloadManager: PlaylistStreamDownloadManagerDelegate { fileQueue.addOperation { [weak self] in self?.fileDelegate.cancelDownload(itemId: itemId) } + + dataQueue.addOperation { [weak self] in + self?.dataDelegate.cancelDownload(itemId: itemId) + } } } func downloadTask(for itemId: String) -> MediaDownloadTask? { if Thread.current.isMainThread { - return hlsDelegate.downloadTask(for: itemId) ?? fileDelegate.downloadTask(for: itemId) + return hlsDelegate.downloadTask(for: itemId) ?? fileDelegate.downloadTask(for: itemId) ?? dataDelegate.downloadTask(for: itemId) } let group = DispatchGroup() @@ -157,9 +188,17 @@ public class PlaylistDownloadManager: PlaylistStreamDownloadManagerDelegate { guard let self = self else { return } fileTask = self.fileDelegate.downloadTask(for: itemId) } + + group.enter() + var dataTask: MediaDownloadTask? + dataQueue.addOperation { [weak self] in + defer { group.leave() } + guard let self = self else { return } + dataTask = self.dataDelegate.downloadTask(for: itemId) + } group.wait() - return hlsTask ?? fileTask + return hlsTask ?? fileTask ?? dataTask } // MARK: - PlaylistStreamDownloadManagerDelegate @@ -645,3 +684,177 @@ private class PlaylistFileDownloadManager: NSObject, URLSessionDownloadDelegate } } } + +private class PlaylistDataDownloadManager: NSObject, URLSessionDataDelegate { + private var activeDownloadTasks = [URLSessionTask: MediaDownloadTask]() + private var pendingCancellationTasks = [URLSessionTask]() + + weak var delegate: PlaylistStreamDownloadManagerDelegate? + + func restoreSession(_ session: URLSession, completion: @escaping () -> Void) { + session.getAllTasks { [weak self] tasks in + defer { + DispatchQueue.main.async { + completion() + } + } + + guard let self = self else { return } + + for task in tasks { + guard let itemId = task.taskDescription else { + continue + } + + DispatchQueue.main.async { + if task.state != .completed, + let item = PlaylistItem.getItem(uuid: itemId), + let assetUrl = URL(string: item.mediaSrc) { + let info = PlaylistInfo(item: item) + let asset = MediaDownloadTask(id: info.tagId, name: info.name, asset: AVURLAsset(url: assetUrl, options: AVAsset.defaultOptions)) + self.activeDownloadTasks[task] = asset + } + } + } + } + } + + func downloadAsset(_ session: URLSession, assetUrl: URL, for item: PlaylistInfo) { + let asset = AVURLAsset(url: assetUrl, options: AVAsset.defaultOptions) + + let request: URLRequest = { + var request = URLRequest(url: assetUrl, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10.0) + + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range + request.addValue("bytes=0-", forHTTPHeaderField: "Range") + request.addValue(UUID().uuidString, forHTTPHeaderField: "X-Playback-Session-Id") + request.addValue(UserAgent.shouldUseDesktopMode ? UserAgent.desktop : UserAgent.mobile, forHTTPHeaderField: "User-Agent") + return request + }() + + let task = session.dataTask(with: request) + + task.taskDescription = item.tagId + activeDownloadTasks[task] = MediaDownloadTask(id: item.tagId, name: item.name, asset: asset) + task.resume() + + DispatchQueue.main.async { + self.delegate?.onDownloadStateChanged(streamDownloader: self, id: item.tagId, state: .inProgress, displayName: nil, error: nil) + } + } + + func cancelDownload(itemId: String) { + if let task = activeDownloadTasks.first(where: { $0.value.id == itemId })?.key { + task.cancel() // will call didCompleteWithError which will cleanup the assets + } + } + + func downloadTask(for itemId: String) -> MediaDownloadTask? { + return activeDownloadTasks.first(where: { $0.value.id == itemId })?.value + } + + // MARK: - URLSessionDataDelegate + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let task = task as? URLSessionDownloadTask, + let asset = activeDownloadTasks.removeValue(forKey: task) else { return } + + if let error = error as NSError? { + switch (error.domain, error.code) { + case (NSURLErrorDomain, NSURLErrorCancelled): + if let cacheLocation = delegate?.localAsset(for: asset.id)?.url { + do { + try FileManager.default.removeItem(at: cacheLocation) + PlaylistItem.updateCache(uuid: asset.id, cachedData: nil) + } catch { + Logger.module.error("Could not delete asset cache \(asset.name): \(error.localizedDescription)") + } + } + + // Update the asset state, but do not propagate the error + // because the download was cancelled by the user + if pendingCancellationTasks.contains(task) { + pendingCancellationTasks.removeAll(where: { $0 == task }) + DispatchQueue.main.async { + self.delegate?.onDownloadStateChanged(streamDownloader: self, id: asset.id, state: .invalid, displayName: nil, error: nil) + } + return + } + + case (NSURLErrorDomain, NSURLErrorUnknown): + assertionFailure("Downloading HLS streams is not supported on the simulator.") + + default: + assertionFailure("An unknown error occurred while attempting to download the playlist item: \(error.domain)") + } + + DispatchQueue.main.async { + self.delegate?.onDownloadStateChanged(streamDownloader: self, id: asset.id, state: .invalid, displayName: nil, error: error) + } + } + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard let asset = activeDownloadTasks[dataTask] else { return } + + DispatchQueue.main.async { + self.delegate?.onDownloadProgressUpdate(streamDownloader: self, id: asset.id, percentComplete: 0.0) + } + + func cleanupAndFailDownload(location: URL?, error: Error) { + if let location = location { + do { + try FileManager.default.removeItem(at: location) + } catch { + Logger.module.error("Error Deleting Playlist Item: \(error.localizedDescription)") + } + } + + DispatchQueue.main.async { + PlaylistItem.updateCache(uuid: asset.id, cachedData: nil) + self.delegate?.onDownloadStateChanged(streamDownloader: self, id: asset.id, state: .invalid, displayName: nil, error: error) + } + } + + let path: URL? = { + do { + guard let path = try PlaylistDownloadManager.uniqueDownloadPathForFilename(asset.name + ".mp4") else { + Logger.module.error("Failed to create unique path for playlist item.") + return nil + } + return path + } catch { + return nil + } + }() + + guard let path = path else { + DispatchQueue.main.async { + self.delegate?.onDownloadStateChanged(streamDownloader: self, id: asset.id, state: .invalid, displayName: nil, error: PlaylistDownloadError.uniquePathNotCreated) + } + return + } + + do { + try data.write(to: path, options: .atomic) + do { + let cachedData = try path.bookmarkData() + + DispatchQueue.main.async { + PlaylistItem.updateCache(uuid: asset.id, cachedData: cachedData) + self.delegate?.onDownloadStateChanged(streamDownloader: self, id: asset.id, state: .downloaded, displayName: nil, error: nil) + } + } catch { + Logger.module.error("Failed to create bookmarkData for download URL.") + cleanupAndFailDownload(location: path, error: error) + } + } catch { + Logger.module.error("An error occurred attempting to download a playlist item: \(error.localizedDescription)") + cleanupAndFailDownload(location: path, error: error) + } + + DispatchQueue.main.async { + self.delegate?.onDownloadProgressUpdate(streamDownloader: self, id: asset.id, percentComplete: 100.0) + } + } +} diff --git a/Sources/Playlist/PlaylistManager.swift b/Sources/Playlist/PlaylistManager.swift index 85e393eac0b..d9edf9f672c 100644 --- a/Sources/Playlist/PlaylistManager.swift +++ b/Sources/Playlist/PlaylistManager.swift @@ -275,6 +275,13 @@ public class PlaylistManager: NSObject { public func download(item: PlaylistInfo) { guard downloadManager.downloadTask(for: item.tagId) == nil, let assetUrl = URL(string: item.src) else { return } Task { + if assetUrl.scheme == "data" { + DispatchQueue.main.async { + self.downloadManager.downloadDataAsset(assetUrl, for: item) + } + return + } + let mimeType = await PlaylistMediaStreamer.getMimeType(assetUrl) guard let mimeType = mimeType?.lowercased() else { return }