diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistLegacyCarplayController.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistLegacyCarplayController.swift index 7db4165ffe68..41a67ee74819 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistLegacyCarplayController.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistLegacyCarplayController.swift @@ -825,7 +825,7 @@ extension PlaylistLegacyCarplayController { let cacheState = PlaylistManager.shared.state(for: item.tagId) if cacheState != .invalid { if let index = PlaylistManager.shared.index(of: item.tagId), - let asset = PlaylistManager.shared.assetAtIndex(index) + let asset = PlaylistManager.shared.assetAtIndexSynchronous(index) { do { diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift index 2aff7a8c5dc9..f7a44cf5a57d 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift @@ -1064,7 +1064,7 @@ extension PlaylistViewController: VideoViewDelegate { let cacheState = PlaylistManager.shared.state(for: item.tagId) if cacheState != .invalid { if let index = PlaylistManager.shared.index(of: item.tagId), - let asset = PlaylistManager.shared.assetAtIndex(index) + let asset = PlaylistManager.shared.assetAtIndexSynchronous(index) { do { diff --git a/ios/brave-ios/Sources/Playlist/PlaylistDownloadManager.swift b/ios/brave-ios/Sources/Playlist/PlaylistDownloadManager.swift index c6e8845fa7db..8a0d7d60c82a 100644 --- a/ios/brave-ios/Sources/Playlist/PlaylistDownloadManager.swift +++ b/ios/brave-ios/Sources/Playlist/PlaylistDownloadManager.swift @@ -23,7 +23,8 @@ protocol PlaylistDownloadManagerDelegate: AnyObject { private protocol PlaylistStreamDownloadManagerDelegate: AnyObject { // TODO: Should be async, fix when removing legacy playlist UI - func localAsset(for itemId: String) -> AVURLAsset? + func localAssetSynchronous(for itemId: String) -> AVURLAsset? + func localAsset(for itemId: String) async -> AVURLAsset? func onDownloadProgressUpdate(streamDownloader: Any, id: String, percentComplete: Double) func onDownloadStateChanged( streamDownloader: Any, @@ -229,7 +230,8 @@ public class PlaylistDownloadManager: PlaylistStreamDownloadManagerDelegate { // MARK: - PlaylistStreamDownloadManagerDelegate - func localAsset(for itemId: String) -> AVURLAsset? { + @available(*, deprecated, renamed: "localAsset(for:)", message: "Use async version") + func localAssetSynchronous(for itemId: String) -> AVURLAsset? { guard let item = PlaylistItem.getItem(uuid: itemId), let cachedData = item.cachedData, !cachedData.isEmpty @@ -253,6 +255,30 @@ public class PlaylistDownloadManager: PlaylistStreamDownloadManagerDelegate { } } + func localAsset(for itemId: String) async -> AVURLAsset? { + let cachedData = await MainActor.run { + return PlaylistItem.getItem(uuid: itemId)?.cachedData + } + guard let cachedData = cachedData, !cachedData.isEmpty else { return nil } + + var bookmarkDataIsStale = false + do { + let url = try URL( + resolvingBookmarkData: cachedData, + bookmarkDataIsStale: &bookmarkDataIsStale + ) + + if bookmarkDataIsStale { + return nil + } + + return AVURLAsset(url: url, options: AVAsset.defaultOptions) + } catch { + Logger.module.error("\(error.localizedDescription)") + return nil + } + } + fileprivate func onDownloadProgressUpdate( streamDownloader: Any, id: String, @@ -536,9 +562,7 @@ private class PlaylistHLSDownloadManager: NSObject, AVAssetDownloadDelegate { // HLS streams can be in two spots, we need to delete from both // just in case the download process was in the middle of transferring the asset // to its proper location - if let cacheLocation = await MainActor.run(body: { - delegate?.localAsset(for: asset.id)?.url - }) { + if let cacheLocation = await delegate?.localAsset(for: asset.id)?.url { do { try await AsyncFileManager.default.removeItem(at: cacheLocation) } catch { @@ -741,9 +765,7 @@ private class PlaylistFileDownloadManager: NSObject, URLSessionDownloadDelegate if let error = error as NSError? { switch (error.domain, error.code) { case (NSURLErrorDomain, NSURLErrorCancelled): - if let cacheLocation = await MainActor.run(body: { - delegate?.localAsset(for: asset.id)?.url - }) { + if let cacheLocation = await delegate?.localAsset(for: asset.id)?.url { do { try await AsyncFileManager.default.removeItem(at: cacheLocation) PlaylistItem.updateCache(uuid: asset.id, pageSrc: asset.pageSrc, cachedData: nil) @@ -1064,9 +1086,7 @@ private class PlaylistDataDownloadManager: NSObject, URLSessionDataDelegate { if let error = error as NSError? { switch (error.domain, error.code) { case (NSURLErrorDomain, NSURLErrorCancelled): - if let cacheLocation = await MainActor.run(body: { - delegate?.localAsset(for: asset.id)?.url - }) { + if let cacheLocation = await delegate?.localAsset(for: asset.id)?.url { Task { do { try await AsyncFileManager.default.removeItem(at: cacheLocation) diff --git a/ios/brave-ios/Sources/Playlist/PlaylistManager.swift b/ios/brave-ios/Sources/Playlist/PlaylistManager.swift index a01d67d97ce2..560451f172a0 100644 --- a/ios/brave-ios/Sources/Playlist/PlaylistManager.swift +++ b/ios/brave-ios/Sources/Playlist/PlaylistManager.swift @@ -176,9 +176,23 @@ public class PlaylistManager: NSObject { return nil } - public func assetAtIndex(_ index: Int) -> AVURLAsset? { + @available(*, deprecated, renamed: "assetAtIndex(_:)", message: "Use async version") + public func assetAtIndexSynchronous(_ index: Int) -> AVURLAsset? { if let item = itemAtIndex(index) { - return asset(for: item.tagId, mediaSrc: item.src) + return assetSynchronous(for: item.tagId, mediaSrc: item.src) + } + return nil + } + + public func assetAtIndex(_ index: Int) async -> AVURLAsset? { + let data: (tagId: String, src: String)? = await MainActor.run { + if let item = itemAtIndex(index) { + return (item.tagId, item.src) + } + return nil + } + if let data = data { + return await asset(for: data.tagId, mediaSrc: data.src) } return nil } @@ -224,12 +238,13 @@ public class PlaylistManager: NSObject { } } + @available(*, deprecated, renamed: "downloadState(for:)", message: "Use async version") public func state(for itemId: String) -> PlaylistDownloadManager.DownloadState { if downloadManager.downloadTask(for: itemId) != nil { return .inProgress } - if let assetUrl = downloadManager.localAsset(for: itemId)?.url { + if let assetUrl = downloadManager.localAssetSynchronous(for: itemId)?.url { if FileManager.default.fileExists(atPath: assetUrl.path) { return .downloaded } @@ -238,10 +253,24 @@ public class PlaylistManager: NSObject { return .invalid } + public func downloadState(for itemId: String) async -> PlaylistDownloadManager.DownloadState { + if downloadManager.downloadTask(for: itemId) != nil { + return .inProgress + } + + if let assetUrl = await downloadManager.localAsset(for: itemId)?.url { + if await AsyncFileManager.default.fileExists(atPath: assetUrl.path) { + return .downloaded + } + } + + return .invalid + } + @available(iOS, deprecated) public func sizeOfDownloadedItemSynchronous(for itemId: String) -> String? { var isDirectory: ObjCBool = false - if let asset = downloadManager.localAsset(for: itemId), + if let asset = downloadManager.localAssetSynchronous(for: itemId), FileManager.default.fileExists(atPath: asset.url.path, isDirectory: &isDirectory) { @@ -651,12 +680,25 @@ public class PlaylistManager: NSObject { } extension PlaylistManager { - private func asset(for itemId: String, mediaSrc: String) -> AVURLAsset { + @available(*, deprecated, renamed: "asset(for:mediaSrc:)", message: "Use async version") + private func assetSynchronous(for itemId: String, mediaSrc: String) -> AVURLAsset { if let task = downloadManager.downloadTask(for: itemId) { return task.asset } - if let asset = downloadManager.localAsset(for: itemId) { + if let asset = downloadManager.localAssetSynchronous(for: itemId) { + return asset + } + + return AVURLAsset(url: URL(string: mediaSrc)!, options: AVAsset.defaultOptions) + } + + private func asset(for itemId: String, mediaSrc: String) async -> AVURLAsset { + if let task = downloadManager.downloadTask(for: itemId) { + return task.asset + } + + if let asset = await downloadManager.localAsset(for: itemId) { return asset } @@ -733,6 +775,12 @@ extension PlaylistManager { item: PlaylistInfo, _ completion: @escaping (TimeInterval?) -> Void ) { + Task { @MainActor in + completion(await fetchAssetDuration(item: item)) + } + } + + @MainActor private func fetchAssetDuration(item: PlaylistInfo) async -> TimeInterval? { let tolerance: Double = 0.00001 let distance = abs(item.duration.distance(to: 0.0)) @@ -740,29 +788,26 @@ extension PlaylistManager { if item.duration.isInfinite || abs(item.duration.distance(to: TimeInterval.greatestFiniteMagnitude)) < tolerance { - completion(TimeInterval.infinity) - return + return TimeInterval.infinity } // If the database duration is 0.0 if distance >= tolerance { // Return the database duration - completion(item.duration) - return + return item.duration } // Attempt to retrieve the duration from the Asset file let asset: AVURLAsset if item.src.isEmpty || item.pageSrc.isEmpty { - if let index = index(of: item.tagId), let urlAsset = assetAtIndex(index) { + if let index = index(of: item.tagId), let urlAsset = await assetAtIndex(index) { asset = urlAsset } else { // Return the database duration - completion(item.duration) - return + return item.duration } } else { - asset = self.asset(for: item.tagId, mediaSrc: item.src) + asset = await self.asset(for: item.tagId, mediaSrc: item.src) } // Accessing tracks blocks the main-thread if not already loaded @@ -776,11 +821,10 @@ extension PlaylistManager { ?? asset.tracks(withMediaType: .audio).first { if track.timeRange.duration.isIndefinite { - completion(TimeInterval.infinity) + return TimeInterval.infinity } else { - completion(track.timeRange.duration.seconds) + return track.timeRange.duration.seconds } - return } } @@ -790,86 +834,49 @@ extension PlaylistManager { if durationStatus == .loaded { // If it's live/indefinite if asset.duration.isIndefinite { - completion(TimeInterval.infinity) - return + return TimeInterval.infinity } // If it's a valid duration if abs(asset.duration.seconds.distance(to: 0.0)) >= tolerance { - completion(asset.duration.seconds) - return + return asset.duration.seconds } } switch Reach().connectionStatus() { case .offline, .unknown: - completion(item.duration) // Return the database duration - return + return item.duration // Return the database duration case .online: break } + assetInformation.append(PlaylistAssetFetcher(itemId: item.tagId, asset: asset)) // We can't get the duration synchronously so we need to let the AVAsset load the media item // and hopefully we get a valid duration from that. - DispatchQueue.global(qos: .userInitiated).async { - asset.loadValuesAsynchronously(forKeys: ["playable", "tracks", "duration"]) { - var error: NSError? - let trackStatus = asset.statusOfValue(forKey: "tracks", error: &error) - if let error = error { - Logger.module.error("AVAsset.statusOfValue error occurred: \(error.localizedDescription)") - } - - let durationStatus = asset.statusOfValue(forKey: "tracks", error: &error) - if let error = error { - Logger.module.error("AVAsset.statusOfValue error occurred: \(error.localizedDescription)") - } - - if trackStatus == .cancelled || durationStatus == .cancelled { - Logger.module.error("Asset Duration Fetch Cancelled") - - DispatchQueue.main.async { - completion(nil) - } - return - } - - if trackStatus == .failed && durationStatus == .failed, let error = error { - if error.code == NSURLErrorNoPermissionsToReadFile { - // Media item is expired.. permission is denied - Logger.module.debug("Playlist Media Item Expired: \(item.pageSrc)") - - DispatchQueue.main.async { - completion(nil) - } - } else { - Logger.module.error( - "An unknown error occurred while attempting to fetch track and duration information: \(error.localizedDescription)" - ) - - DispatchQueue.main.async { - completion(nil) - } - } - - return - } - + return await Task.detached { + do { + let (_, loadedTracks, loadedDuration) = try await asset.load( + .isPlayable, + .tracks, + .duration + ) var duration: CMTime = .zero - if trackStatus == .loaded { - if let track = asset.tracks(withMediaType: .video).first - ?? asset.tracks(withMediaType: .audio).first + if case .loaded = asset.status(of: .tracks) { + if let track = loadedTracks.first(where: { $0.mediaType == .video }) + ?? loadedTracks.first(where: { $0.mediaType == .audio }) { duration = track.timeRange.duration } else { - duration = asset.duration + duration = loadedDuration } - } else if durationStatus == .loaded { - duration = asset.duration + } else { + duration = loadedDuration } - DispatchQueue.main.async { + // Jump back to main for CoreData + return await Task { @MainActor in if duration.isIndefinite { - completion(TimeInterval.infinity) + return TimeInterval.infinity } else if abs(duration.seconds.distance(to: 0.0)) > tolerance { let newItem = PlaylistInfo( name: item.name, @@ -889,20 +896,32 @@ extension PlaylistManager { if PlaylistItem.itemExists(uuid: item.tagId) || PlaylistItem.itemExists(pageSrc: item.pageSrc) { - PlaylistItem.updateItem(newItem) { - completion(duration.seconds) + await withCheckedContinuation { continuation in + PlaylistItem.updateItem(newItem) { + continuation.resume() + } } + return duration.seconds } else { - completion(duration.seconds) + return duration.seconds } } else { - completion(duration.seconds) + return duration.seconds + } + }.value + } catch { + if (error as NSError).code == NSURLErrorNoPermissionsToReadFile { + // Media item is expired.. permission is denied + await MainActor.run { + // Have to jump to main due to access of CoreData + Logger.module.debug("Playlist Media Item Expired: \(item.pageSrc)") } + } else { + Logger.module.error("Failed to load asset details: \(error.localizedDescription)") } + return nil } - } - - assetInformation.append(PlaylistAssetFetcher(itemId: item.tagId, asset: asset)) + }.value } } diff --git a/ios/brave-ios/Sources/Playlist/PlaylistMediaStreamer.swift b/ios/brave-ios/Sources/Playlist/PlaylistMediaStreamer.swift index 058f2a7dbc7b..108c0ad04fac 100644 --- a/ios/brave-ios/Sources/Playlist/PlaylistMediaStreamer.swift +++ b/ios/brave-ios/Sources/Playlist/PlaylistMediaStreamer.swift @@ -38,7 +38,7 @@ public class PlaylistMediaStreamer { // We need to check if the item is cached locally. // If the item is cached (downloaded) // then we can play it directly without having to stream it. - let cacheState = PlaylistManager.shared.state(for: item.tagId) + let cacheState = await PlaylistManager.shared.downloadState(for: item.tagId) if cacheState != .invalid { return item } diff --git a/ios/brave-ios/Sources/PlaylistUI/PlayerModel.swift b/ios/brave-ios/Sources/PlaylistUI/PlayerModel.swift index f269810f2b4b..c21557fb260c 100644 --- a/ios/brave-ios/Sources/PlaylistUI/PlayerModel.swift +++ b/ios/brave-ios/Sources/PlaylistUI/PlayerModel.swift @@ -127,16 +127,7 @@ public final class PlayerModel: ObservableObject { } } - var duration: ItemDuration { - guard let duration = player.currentItem?.asset.duration else { return .unknown } - if !duration.isValid { - return .unknown - } - if duration.isIndefinite { - return .indefinite - } - return .seconds(duration.seconds) - } + @Published private(set) var duration: ItemDuration = .unknown var currentTimeStream: AsyncStream { return .init { [weak self] continuation in @@ -610,6 +601,7 @@ public final class PlayerModel: ObservableObject { playImmediately: Bool ) async { guard let item = selectedItem else { return } + duration = .unknown var playerItemToReplace: AVPlayerItem? if let cachedData = item.cachedData { if let cachedDataURL = await PlaylistItem.resolvingCachedData(cachedData) { @@ -657,6 +649,12 @@ public final class PlayerModel: ObservableObject { } let resumeFromLastTimePlayed = Preferences.Playlist.playbackLeftOff.value if let playerItem = playerItemToReplace { + if let (_, _, duration) = try? await playerItem.asset.load(.isPlayable, .tracks, .duration) { + self.duration = itemDurationForAssetDuration(duration) + } + if playImmediately { + pause() + } await updateCurrentItem(playerItem) if let initialOffset { await seek(to: initialOffset, accurately: true) @@ -669,6 +667,16 @@ public final class PlayerModel: ObservableObject { } } + private func itemDurationForAssetDuration(_ duration: CMTime) -> ItemDuration { + if !duration.isValid { + return .unknown + } + if duration.isIndefinite { + return .indefinite + } + return .seconds(duration.seconds) + } + // MARK: - private let player: AVPlayer = .init() @@ -1031,6 +1039,10 @@ extension PlayerModel.RepeatMode { } } +extension PlayerModel { + var playerForTesting: AVPlayer { player } +} + #if DEBUG extension PlayerModel { static let preview: PlayerModel = .init(mediaStreamer: nil, initialPlaybackInfo: nil) diff --git a/ios/brave-ios/Sources/PlaylistUI/PlaylistSidebarList.swift b/ios/brave-ios/Sources/PlaylistUI/PlaylistSidebarList.swift index 23bc9b1e3f58..729105867df4 100644 --- a/ios/brave-ios/Sources/PlaylistUI/PlaylistSidebarList.swift +++ b/ios/brave-ios/Sources/PlaylistUI/PlaylistSidebarList.swift @@ -155,10 +155,10 @@ struct PlaylistSidebarList: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .onAppear { + .task { for item in items { guard let uuid = item.uuid else { continue } - downloadStates[uuid] = PlaylistManager.shared.state(for: uuid) + downloadStates[uuid] = await PlaylistManager.shared.downloadState(for: uuid) } } .onReceive(PlaylistManager.shared.downloadStateChanged) { output in diff --git a/ios/brave-ios/Tests/PlaylistUITests/PlayerModelTests.swift b/ios/brave-ios/Tests/PlaylistUITests/PlayerModelTests.swift index bb2b13ab0a36..415c69bb4f6b 100644 --- a/ios/brave-ios/Tests/PlaylistUITests/PlayerModelTests.swift +++ b/ios/brave-ios/Tests/PlaylistUITests/PlayerModelTests.swift @@ -499,4 +499,26 @@ class PlayerModelTests: CoreDataTestCase { XCTAssertTrue(playerModel.isPlaying) XCTAssertEqual(playerModel.selectedItemID, playerModel.itemQueue.first) } + + @MainActor func testDurationAvailableOnLoad() async throws { + let folder = try await addFolder() + await addMockItems(count: 1, to: folder) + + let items = PlaylistItem.getItems(parentFolder: folder) + + let playerModel = PlayerModel(mediaStreamer: nil, initialPlaybackInfo: nil) + playerModel.selectedFolderID = folder.id + playerModel.selectedItemID = items[0].id + // Required for the player to actually play immediately in unit tests + playerModel.playerLayer.player?.automaticallyWaitsToMinimizeStalling = false + await playerModel.prepareToPlaySelectedItem(initialOffset: 0, playImmediately: false) + + let status = playerModel.playerForTesting.currentItem?.asset.status(of: .duration) + switch status { + case .loaded: + break + default: + XCTFail("Duration should already be loaded, but is currently \(String(describing: status))") + } + } }