diff --git a/App/Client.xcodeproj/project.pbxproj b/App/Client.xcodeproj/project.pbxproj index 79568598f2d..d085aa16888 100644 --- a/App/Client.xcodeproj/project.pbxproj +++ b/App/Client.xcodeproj/project.pbxproj @@ -19,6 +19,8 @@ 27466BB1288EDB9C00584C90 /* RuntimeWarnings in Frameworks */ = {isa = PBXBuildFile; productRef = 27466BB0288EDB9C00584C90 /* RuntimeWarnings */; }; 2759468D29CCB62F0094BDDE /* GRDWireGuardKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2759468C29CCB62F0094BDDE /* GRDWireGuardKit */; }; 2759468F29CCB6350094BDDE /* GRDWireGuardKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2759468E29CCB6350094BDDE /* GRDWireGuardKit */; }; + 278852E42ACDC26B005395CF /* Playlist in Frameworks */ = {isa = PBXBuildFile; productRef = 278852E32ACDC26B005395CF /* Playlist */; }; + 278852E62ACDE795005395CF /* UserAgent in Frameworks */ = {isa = PBXBuildFile; productRef = 278852E52ACDE795005395CF /* UserAgent */; }; 278BB9FF297B0A1E00690BE3 /* TopNewsListWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278BB9FE297B0A1E00690BE3 /* TopNewsListWidget.swift */; }; 279A828E28FEF01E00F55A5E /* BraveWidgets.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = CA0391E5271E1382000EB13C /* BraveWidgets.intentdefinition */; settings = {ATTRIBUTES = (no_codegen, ); }; }; 279A829228FEF0BF00F55A5E /* BraveShared in Frameworks */ = {isa = PBXBuildFile; productRef = 279A829128FEF0BF00F55A5E /* BraveShared */; }; @@ -356,7 +358,9 @@ 27D7FF5329BA69330096DD93 /* BraveShields in Frameworks */, 27FA217F2837FB0100FA2C45 /* BraveShared in Frameworks */, 27D7FF7329BB816E0096DD93 /* PrivateCDN in Frameworks */, + 278852E62ACDE795005395CF /* UserAgent in Frameworks */, 27FA21812837FB0100FA2C45 /* BraveWallet in Frameworks */, + 278852E42ACDC26B005395CF /* Playlist in Frameworks */, 0A1DF486244A2ECB00541FE4 /* NetworkExtension.framework in Frameworks */, 27466BB1288EDB9C00584C90 /* RuntimeWarnings in Frameworks */, ); @@ -798,6 +802,8 @@ 27C1C5EC29B6AA8800739BE5 /* Preferences */, 27D7FF5229BA69330096DD93 /* BraveShields */, 27D7FF7229BB816E0096DD93 /* PrivateCDN */, + 278852E32ACDC26B005395CF /* Playlist */, + 278852E52ACDE795005395CF /* UserAgent */, ); productName = Client; productReference = F84B21BE1A090F8100AAB793 /* Client.app */; @@ -3130,6 +3136,14 @@ isa = XCSwiftPackageProductDependency; productName = GRDWireGuardKit; }; + 278852E32ACDC26B005395CF /* Playlist */ = { + isa = XCSwiftPackageProductDependency; + productName = Playlist; + }; + 278852E52ACDE795005395CF /* UserAgent */ = { + isa = XCSwiftPackageProductDependency; + productName = UserAgent; + }; 279A829128FEF0BF00F55A5E /* BraveShared */ = { isa = XCSwiftPackageProductDependency; productName = BraveShared; diff --git a/App/iOS/Delegates/AppDelegate.swift b/App/iOS/Delegates/AppDelegate.swift index 1cf86960d53..876b3cd9284 100644 --- a/App/iOS/Delegates/AppDelegate.swift +++ b/App/iOS/Delegates/AppDelegate.swift @@ -29,6 +29,8 @@ import BraveWallet import Preferences import BraveShields import PrivateCDN +import Playlist +import UserAgent @main class AppDelegate: UIResponder, UIApplicationDelegate { diff --git a/App/iOS/Delegates/AppState.swift b/App/iOS/Delegates/AppState.swift index ca17be7eb82..380c74e3ce1 100644 --- a/App/iOS/Delegates/AppState.swift +++ b/App/iOS/Delegates/AppState.swift @@ -15,6 +15,7 @@ import Preferences import Storage import BraveNews import os.log +import UserAgent private let adsRewardsLog = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ads-rewards") diff --git a/Package.swift b/Package.swift index 79c3c85d01d..5c6109c728e 100644 --- a/Package.swift +++ b/Package.swift @@ -38,6 +38,8 @@ var package = Package( .library(name: "Preferences", targets: ["Preferences"]), .library(name: "PrivateCDN", targets: ["PrivateCDN"]), .library(name: "CertificateUtilities", targets: ["CertificateUtilities"]), + .library(name: "Playlist", targets: ["Playlist"]), + .library(name: "UserAgent", targets: ["UserAgent"]), .executable(name: "LeoAssetCatalogGenerator", targets: ["LeoAssetCatalogGenerator"]), .plugin(name: "IntentBuilderPlugin", targets: ["IntentBuilderPlugin"]), .plugin(name: "LoggerPlugin", targets: ["LoggerPlugin"]), @@ -280,6 +282,8 @@ var package = Package( ], plugins: ["LoggerPlugin"] ), + .target(name: "UserAgent", dependencies: ["Preferences"]), + .testTarget(name: "UserAgentTests", dependencies: ["UserAgent", "Brave"]), .testTarget(name: "SharedTests", dependencies: ["Shared"]), .testTarget( name: "BraveSharedTests", @@ -321,6 +325,11 @@ var package = Package( .target(name: "Strings"), .target(name: "RuntimeWarnings"), .target(name: "PrivateCDN", dependencies: ["SDWebImage"]), + .target( + name: "Playlist", + dependencies: ["Data", "BraveShared", "Shared", "Storage", "Preferences", "Strings", "CodableHelpers", "UserAgent", "Then"], + plugins: ["LoggerPlugin"] + ), .testTarget(name: "PrivateCDNTests", dependencies: ["PrivateCDN"]), .testTarget(name: "GrowthTests", dependencies: ["Growth", "Shared", "BraveShared", "BraveVPN"]), .plugin(name: "IntentBuilderPlugin", capability: .buildTool()), @@ -359,6 +368,8 @@ var braveTarget: PackageDescription.Target = .target( "Preferences", "Favicon", "CertificateUtilities", + "Playlist", + "UserAgent", .product(name: "Lottie", package: "lottie-ios"), .product(name: "Collections", package: "swift-collections"), ], diff --git a/Sources/Brave/Frontend/Browser/BraveWebView.swift b/Sources/Brave/Frontend/Browser/BraveWebView.swift index 02112e99d5e..825446043ea 100644 --- a/Sources/Brave/Frontend/Browser/BraveWebView.swift +++ b/Sources/Brave/Frontend/Browser/BraveWebView.swift @@ -6,6 +6,7 @@ import Foundation import WebKit import Shared import BraveShared +import UserAgent class BraveWebView: WKWebView { lazy var findInPageDelegate: WKWebViewFindStringFindDelegate? = { diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController.swift b/Sources/Brave/Frontend/Browser/BrowserViewController.swift index c283498d628..ae67f3a8977 100644 --- a/Sources/Brave/Frontend/Browser/BrowserViewController.swift +++ b/Sources/Brave/Frontend/Browser/BrowserViewController.swift @@ -440,7 +440,7 @@ public class BrowserViewController: UIViewController { // Observe some user preferences Preferences.Privacy.privateBrowsingOnly.observe(from: self) Preferences.General.tabBarVisibility.observe(from: self) - Preferences.General.alwaysRequestDesktopSite.observe(from: self) + Preferences.UserAgent.alwaysRequestDesktopSite.observe(from: self) Preferences.General.enablePullToRefresh.observe(from: self) Preferences.General.mediaAutoBackgrounding.observe(from: self) Preferences.General.youtubeHighQuality.observe(from: self) @@ -3099,7 +3099,7 @@ extension BrowserViewController: PreferencesObserver { setupTabs() updateTabsBarVisibility() updateApplicationShortcuts() - case Preferences.General.alwaysRequestDesktopSite.key: + case Preferences.UserAgent.alwaysRequestDesktopSite.key: tabManager.reset() tabManager.reloadSelectedTab() case Preferences.General.enablePullToRefresh.key: diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+ToolbarDelegate.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+ToolbarDelegate.swift index 73bdbd77f65..19c0f8ed8c3 100644 --- a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+ToolbarDelegate.swift +++ b/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+ToolbarDelegate.swift @@ -17,6 +17,7 @@ import BraveWallet import Preferences import CertificateUtilities import AVFoundation +import Playlist // MARK: - TopToolbarDelegate diff --git a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+WKNavigationDelegate.swift b/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+WKNavigationDelegate.swift index 18ecfe32195..81b5d3e115d 100644 --- a/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+WKNavigationDelegate.swift +++ b/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController+WKNavigationDelegate.swift @@ -16,6 +16,7 @@ import Favicon import Growth import SafariServices import LocalAuthentication +import BraveShared extension WKNavigationAction { /// Allow local requests only if the request is privileged. diff --git a/Sources/Brave/Frontend/Browser/Playlist/Browser/BrowserViewController+Playlist.swift b/Sources/Brave/Frontend/Browser/Playlist/Browser/BrowserViewController+Playlist.swift index 57408f72b7a..be945091442 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Browser/BrowserViewController+Playlist.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Browser/BrowserViewController+Playlist.swift @@ -13,12 +13,13 @@ import UIKit import Growth import os.log import Onboarding +import Playlist extension BrowserViewController: PlaylistScriptHandlerDelegate, PlaylistFolderSharingScriptHandlerDelegate { static var didShowStorageFullWarning = false func createPlaylistPopover(item: PlaylistInfo, tab: Tab?) -> PopoverController { - let folderName = PlaylistItem.getItem(uuid: item.tagId)?.playlistFolder?.title ?? Strings.PlaylistFolders.playlistSavedFolderTitle + let folderName = PlaylistItem.getItem(uuid: item.tagId)?.playlistFolder?.title ?? Strings.Playlist.defaultPlaylistTitle return PopoverController( content: PlaylistPopoverView(folderName: folderName) { [weak self] action in diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift index f72f57831a9..6a6db325390 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift @@ -13,6 +13,7 @@ import Shared import CoreData import Favicon import os.log +import Playlist private enum PlaylistCarPlayTemplateID: String { case folders diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistDetailViewController.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistDetailViewController.swift index c40aa4ee348..7f34348b4d2 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistDetailViewController.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistDetailViewController.swift @@ -8,6 +8,7 @@ import Preferences import Shared import Data import UIKit +import Playlist class PlaylistDetailViewController: UIViewController, UIGestureRecognizerDelegate { diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistFolderController.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistFolderController.swift index 2683b974b9d..ea02edc664f 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistFolderController.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistFolderController.swift @@ -13,6 +13,7 @@ import Shared import BraveShared import os.log import Growth +import Playlist private enum Section: Int, CaseIterable { case savedItems @@ -201,7 +202,7 @@ extension PlaylistFolderController: UITableViewDataSource { let itemCount = savedFolder?.playlistItems?.count ?? 0 cell.imageView?.image = folderIcon - cell.textLabel?.text = Strings.PlaylistFolders.playlistSavedFolderTitle + cell.textLabel?.text = Strings.Playlist.defaultPlaylistTitle cell.detailTextLabel?.text = "\(itemCount == 1 ? Strings.PlaylistFolders.playlistFolderSubtitleItemSingleCount : String.localizedStringWithFormat(Strings.PlaylistFolders.playlistFolderSubtitleItemCount, itemCount))" cell.detailTextLabel?.textColor = .secondaryBraveLabel cell.accessoryType = .disclosureIndicator diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+DragDropDelegate.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+DragDropDelegate.swift index 79f06ac933c..a736d21d9b6 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+DragDropDelegate.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+DragDropDelegate.swift @@ -6,6 +6,7 @@ import Foundation import UIKit import BraveUI +import Playlist // MARK: - Reordering of cells diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift index cabdae356e8..802dbab8daa 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift @@ -14,6 +14,7 @@ import BraveUI import Preferences import Favicon import os.log +import Playlist // MARK: UITableViewDataSource diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDelegate.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDelegate.swift index bba63432faf..88bd894a589 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDelegate.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDelegate.swift @@ -12,6 +12,7 @@ import Data import MediaPlayer import os.log import Preferences +import Playlist private extension PlaylistListViewController { func shareItem(_ item: PlaylistInfo, anchorView: UIView?) { diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController.swift index 998a3dc8684..876c2b85d6e 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistListViewController.swift @@ -10,6 +10,7 @@ import AVKit import CoreData import Combine import os.log +import BraveShared // Third-Party import SDWebImage @@ -19,6 +20,7 @@ import Shared import Data import SwiftUI import Growth +import Playlist // MARK: - PlaylistListViewController @@ -704,7 +706,7 @@ extension PlaylistListViewController { } } - let items = try await PlaylistSharedFolderNetwork.fetchMediaItemInfo(item: model, viewForInvisibleWebView: self.playerView.window ?? self.playerView.superview ?? self.playerView) + let items = try await PlaylistSharedFolderNetwork.fetchMediaItemInfo(item: model, viewForInvisibleWebView: self.playerView.window ?? self.playerView.superview ?? self.playerView, webLoaderFactory: LivePlaylistWebLoaderFactory()) try Task.checkCancellation() try folder.playlistItems?.forEach({ playlistItem in diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistMoveFolderView.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistMoveFolderView.swift index 378540f2278..486c3356ed0 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistMoveFolderView.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistMoveFolderView.swift @@ -10,6 +10,7 @@ import CoreData import Shared import BraveShared import BraveUI +import Playlist private struct PlaylistFolderImage: View { let item: PlaylistItem diff --git a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift index bb7effae2ec..400a7cc7241 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift @@ -10,7 +10,7 @@ import AVFoundation import CarPlay import MediaPlayer import Combine - +import Playlist import Preferences import Shared import SDWebImage @@ -76,7 +76,7 @@ class PlaylistViewController: UIViewController { self.openInNewTab = openInNewTab self.openPlaylistSettingsMenu = openPlaylistSettingsMenu self.player = mediaPlayer - self.mediaStreamer = PlaylistMediaStreamer(playerView: playerView) + self.mediaStreamer = PlaylistMediaStreamer(playerView: playerView, webLoaderFactory: LivePlaylistWebLoaderFactory()) self.isPrivateBrowsing = isPrivateBrowsing self.folderSharingUrl = nil diff --git a/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCacheLoader.swift b/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCacheLoader.swift index 9198c18e382..556078eb5e0 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCacheLoader.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCacheLoader.swift @@ -14,280 +14,15 @@ import Preferences import BraveCore import Storage import os.log +import Playlist -// IANA List of Audio types: https://www.iana.org/assignments/media-types/media-types.xhtml#audio -// IANA List of Video types: https://www.iana.org/assignments/media-types/media-types.xhtml#video -// APPLE List of UTI types: https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/UTIRef/Articles/System-DeclaredUniformTypeIdentifiers.html - -public class PlaylistMimeTypeDetector { - private(set) var mimeType: String? - private(set) var fileExtension: String? // When nil, assume `mpg` format. - - init(url: URL) { - let possibleFileExtension = url.pathExtension.lowercased() - if let supportedExtension = knownFileExtensions.first(where: { $0.lowercased() == possibleFileExtension }) { - self.fileExtension = supportedExtension - self.mimeType = mimeTypeMap.first(where: { $0.value == supportedExtension })?.key - } else if let fileExtension = PlaylistMimeTypeDetector.supportedAVAssetFileExtensions().first(where: { $0.lowercased() == possibleFileExtension }) { - self.fileExtension = fileExtension - self.mimeType = UTType(filenameExtension: fileExtension)?.preferredMIMEType - } - } - - init(mimeType: String) { - if let fileExtension = mimeTypeMap[mimeType.lowercased()] { - self.mimeType = mimeType - self.fileExtension = fileExtension - } else if let mimeType = PlaylistMimeTypeDetector.supportedAVAssetMimeTypes().first(where: { $0.lowercased() == mimeType.lowercased() }) { - self.mimeType = mimeType - self.fileExtension = UTType(mimeType: mimeType)?.preferredFilenameExtension - } - } - - init(data: Data) { - // Assume mpg by default. If it can't play, it will fail anyway.. - // AVPlayer REQUIRES that you give a file extension no matter what and will refuse to determine the extension for you without an - // AVResourceLoaderDelegate :S - - if findHeader(offset: 0, data: data, header: [0x1A, 0x45, 0xDF, 0xA3]) { - mimeType = "video/webm" - fileExtension = "webm" - return - } - - if findHeader(offset: 0, data: data, header: [0x1A, 0x45, 0xDF, 0xA3]) { - mimeType = "video/matroska" - fileExtension = "mkv" - return - } - - if findHeader(offset: 0, data: data, header: [0x4F, 0x67, 0x67, 0x53]) { - mimeType = "application/ogg" - fileExtension = "ogg" - return - } - - if findHeader(offset: 0, data: data, header: [0x52, 0x49, 0x46, 0x46]) && findHeader(offset: 8, data: data, header: [0x57, 0x41, 0x56, 0x45]) { - mimeType = "audio/x-wav" - fileExtension = "wav" - return - } - - if findHeader(offset: 0, data: data, header: [0xFF, 0xFB]) || findHeader(offset: 0, data: data, header: [0x49, 0x44, 0x33]) { - mimeType = "audio/mpeg" - fileExtension = "mp4" - return - } - - if findHeader(offset: 0, data: data, header: [0x66, 0x4C, 0x61, 0x43]) { - mimeType = "audio/flac" - fileExtension = "flac" - return - } - - if findHeader(offset: 4, data: data, header: [0x66, 0x74, 0x79, 0x70, 0x4D, 0x53, 0x4E, 0x56]) || findHeader(offset: 4, data: data, header: [0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D]) || findHeader(offset: 4, data: data, header: [0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32]) || findHeader(offset: 0, data: data, header: [0x33, 0x67, 0x70, 0x35]) { - mimeType = "video/mp4" - fileExtension = "mp4" - return - } - - if findHeader(offset: 0, data: data, header: [0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x56]) { - mimeType = "video/x-m4v" - fileExtension = "m4v" - return - } - - if findHeader(offset: 0, data: data, header: [0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70]) { - mimeType = "video/quicktime" - fileExtension = "mov" - return - } - - if findHeader(offset: 0, data: data, header: [0x52, 0x49, 0x46, 0x46]) && findHeader(offset: 8, data: data, header: [0x41, 0x56, 0x49]) { - mimeType = "video/x-msvideo" - fileExtension = "avi" - return - } - - if findHeader(offset: 0, data: data, header: [0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11, 0xA6, 0xD9]) { - mimeType = "video/x-ms-wmv" - fileExtension = "wmv" - return - } - - // Maybe - if findHeader(offset: 0, data: data, header: [0x00, 0x00, 0x01]) { - mimeType = "video/mpeg" - fileExtension = "mpg" - return - } - - if findHeader(offset: 0, data: data, header: [0x49, 0x44, 0x33]) || findHeader(offset: 0, data: data, header: [0xFF, 0xFB]) { - mimeType = "audio/mpeg" - fileExtension = "mp3" - return - } - - if findHeader(offset: 0, data: data, header: [0x4D, 0x34, 0x41, 0x20]) || findHeader(offset: 4, data: data, header: [0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x41]) { - mimeType = "audio/m4a" - fileExtension = "m4a" - return - } - - if findHeader(offset: 0, data: data, header: [0x23, 0x21, 0x41, 0x4D, 0x52, 0x0A]) { - mimeType = "audio/amr" - fileExtension = "amr" - return - } - - if findHeader(offset: 0, data: data, header: [0x46, 0x4C, 0x56, 0x01]) { - mimeType = "video/x-flv" - fileExtension = "flv" - return - } - - mimeType = "application/x-mpegURL" // application/vnd.apple.mpegurl - fileExtension = nil - } - - private func findHeader(offset: Int, data: Data, header: [UInt8]) -> Bool { - if offset < 0 || data.count < offset + header.count { - return false - } - - return [UInt8](data[offset..<(offset + header.count)]) == header - } - - /// Converts a list of AVFileType to a list of file extensions - private static func supportedAVAssetFileExtensions() -> [String] { - let types = AVURLAsset.audiovisualTypes() - return types.compactMap({ UTType($0.rawValue)?.preferredFilenameExtension }).filter({ !$0.isEmpty }) - } - - /// Converts a list of AVFileType to a list of mime-types - private static func supportedAVAssetMimeTypes() -> [String] { - let types = AVURLAsset.audiovisualTypes() - return types.compactMap({ UTType($0.rawValue)?.preferredMIMEType }).filter({ !$0.isEmpty }) +class LivePlaylistWebLoaderFactory: PlaylistWebLoaderFactory { + func makeWebLoader() -> PlaylistWebLoader { + LivePlaylistWebLoader() } - - private let knownFileExtensions = [ - "mov", - "qt", - "mp4", - "m4v", - "m4a", - "m4b", // DRM protected - "m4p", // DRM protected - "3gp", - "3gpp", - "sdv", - "3g2", - "3gp2", - "caf", - "wav", - "wave", - "bwf", - "aif", - "aiff", - "aifc", - "cdda", - "amr", - "mp3", - "au", - "snd", - "ac3", - "eac3", - "flac", - "aac", - "mp2", - "pls", - "avi", - "webm", - "ogg", - "mpg", - "mpg4", - "mpeg", - "mpg3", - "wma", - "wmv", - "swf", - "flv", - "mng", - "asx", - "asf", - "mkv", - ] - - private let mimeTypeMap = [ - "audio/x-wav": "wav", - "audio/vnd.wave": "wav", - "audio/aacp": "aacp", - "audio/mpeg3": "mp3", - "audio/mp3": "mp3", - "audio/x-caf": "caf", - "audio/mpeg": "mp3", // mpg3 - "audio/x-mpeg3": "mp3", - "audio/wav": "wav", - "audio/flac": "flac", - "audio/x-flac": "flac", - "audio/mp4": "mp4", - "audio/x-mpg": "mp3", // maybe mpg3 - "audio/scpls": "pls", - "audio/x-aiff": "aiff", - "audio/usac": "eac3", // Extended AC3 - "audio/x-mpeg": "mp3", - "audio/wave": "wav", - "audio/x-m4r": "m4r", - "audio/x-mp3": "mp3", - "audio/amr": "amr", - "audio/aiff": "aiff", - "audio/3gpp2": "3gp2", - "audio/aac": "aac", - "audio/mpg": "mp3", // mpg3 - "audio/mpegurl": "mpg", // actually .m3u8, .m3u HLS stream - "audio/x-m4b": "m4b", - "audio/x-m4p": "m4p", - "audio/x-scpls": "pls", - "audio/x-mpegurl": "mpg", // actually .m3u8, .m3u HLS stream - "audio/x-aac": "aac", - "audio/3gpp": "3gp", - "audio/basic": "au", - "audio/au": "au", - "audio/snd": "snd", - "audio/x-m4a": "m4a", - "audio/x-realaudio": "ra", - "video/3gpp2": "3gp2", - "video/quicktime": "mov", - "video/mp4": "mp4", - "video/mp4v": "mp4", - "video/mpg": "mpg", - "video/mpeg": "mpeg", - "video/x-mpg": "mpg", - "video/x-mpeg": "mpeg", - "video/avi": "avi", - "video/x-m4v": "m4v", - "video/mp2t": "ts", - "application/vnd.apple.mpegurl": "mpg", // actually .m3u8, .m3u HLS stream - "video/3gpp": "3gp", - "text/vtt": "vtt", // Subtitles format - "application/mp4": "mp4", - "application/x-mpegurl": "mpg", // actually .m3u8, .m3u HLS stream - "video/webm": "webm", - "application/ogg": "ogg", - "video/msvideo": "avi", - "video/x-msvideo": "avi", - "video/x-ms-wmv": "wmv", - "video/x-ms-wma": "wma", - "application/x-shockwave-flash": "swf", - "video/x-flv": "flv", - "video/x-mng": "mng", - "video/x-ms-asx": "asx", - "video/x-ms-asf": "asf", - "video/matroska": "mkv", - ] } -class PlaylistWebLoader: UIView { +class LivePlaylistWebLoader: UIView, PlaylistWebLoader { fileprivate static var pageLoadTimeout = 300.0 private var pendingRequests = [String: URLRequest]() @@ -376,12 +111,12 @@ class PlaylistWebLoader: UIView { } private class PlaylistWebLoaderContentHelper: TabContentScript { - private weak var webLoader: PlaylistWebLoader? + private weak var webLoader: LivePlaylistWebLoader? private var playlistItems = Set() private var isPageLoaded = false private var timeout: DispatchWorkItem? - init(_ webLoader: PlaylistWebLoader) { + init(_ webLoader: LivePlaylistWebLoader) { self.webLoader = webLoader timeout = DispatchWorkItem(block: { [weak self] in @@ -392,7 +127,7 @@ class PlaylistWebLoader: UIView { }) if let timeout = timeout { - DispatchQueue.main.asyncAfter(deadline: .now() + PlaylistWebLoader.pageLoadTimeout, execute: timeout) + DispatchQueue.main.asyncAfter(deadline: .now() + LivePlaylistWebLoader.pageLoadTimeout, execute: timeout) } } @@ -437,7 +172,7 @@ class PlaylistWebLoader: UIView { }) if let timeout = timeout { - DispatchQueue.main.asyncAfter(deadline: .now() + PlaylistWebLoader.pageLoadTimeout, execute: timeout) + DispatchQueue.main.asyncAfter(deadline: .now() + LivePlaylistWebLoader.pageLoadTimeout, execute: timeout) } } return @@ -460,7 +195,7 @@ class PlaylistWebLoader: UIView { }) if let timeout = timeout { - DispatchQueue.main.asyncAfter(deadline: .now() + PlaylistWebLoader.pageLoadTimeout, execute: timeout) + DispatchQueue.main.asyncAfter(deadline: .now() + LivePlaylistWebLoader.pageLoadTimeout, execute: timeout) } return } @@ -493,7 +228,7 @@ class PlaylistWebLoader: UIView { } } -extension PlaylistWebLoader: WKNavigationDelegate { +extension LivePlaylistWebLoader: WKNavigationDelegate { // Recognize an Apple Maps URL. This will trigger the native app. But only if a search query is present. Otherwise // it could just be a visit to a regular page on maps.apple.com. fileprivate func isAppleMapsURL(_ url: URL) -> Bool { diff --git a/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift b/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift index ba490f69e0b..0fd90896ccb 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift @@ -11,6 +11,7 @@ import Shared import Data import Preferences import os.log +import Playlist /// Lightweight class that manages a single MediaPlayer item /// The MediaPlayer is then passed to any controller that needs to use it. @@ -52,6 +53,16 @@ public class PlaylistCarplayManager: NSObject { } } } + + public func destroyPiP() { + // This is the only way to have the system kill picture in picture as the restoration controller is deallocated + // And that means the video is deallocated, its AudioSession is stopped, and the Picture-In-Picture controller is deallocated. + // This is because `AVPictureInPictureController` is NOT a view controller and there is no way to dismiss it + // other than to deallocate the restoration controXller. + // We could also call `AVPictureInPictureController.stopPictureInPicture` BUT we'd still have to deallocate all resources. + // At least this way, we deallocate both AND pip is stopped in the destructor of `PlaylistViewController->ListController` + playlistController = nil + } // There can only ever be one instance of this class // Because there can only be a single AudioSession and MediaPlayer @@ -84,7 +95,7 @@ public class PlaylistCarplayManager: NSObject { // If there is no media player, create one, // pass it to the car-play controller let mediaPlayer = self.mediaPlayer ?? MediaPlayer() - let mediaStreamer = PlaylistMediaStreamer(playerView: currentWindow ?? UIView()) + let mediaStreamer = PlaylistMediaStreamer(playerView: currentWindow ?? UIView(), webLoaderFactory: LivePlaylistWebLoaderFactory()) // Construct the CarPlay UI let carPlayController = PlaylistCarplayController( diff --git a/Sources/Brave/Frontend/Browser/Playlist/Utilities/DataURIParser.swift b/Sources/Brave/Frontend/Browser/Playlist/Utilities/DataURIParser.swift deleted file mode 100644 index 53048022923..00000000000 --- a/Sources/Brave/Frontend/Browser/Playlist/Utilities/DataURIParser.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2020 The Brave Authors. All rights reserved. -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -import Foundation - -enum URIParserError: Error { - case invalidInputData -} - -class DataURIParser { - let mediaType: String - let headers: [String] - let data: Data - - init(uri: String) throws { - if uri.lowercased() == "data:," { - mediaType = "text/plain" - headers = ["charset=US-ASCII"] - data = Data() - return - } - - if !uri.lowercased().hasPrefix("data:") { - throw URIParserError.invalidInputData - } - - guard let infoSegment = uri.firstIndex(of: ",") else { - throw URIParserError.invalidInputData - } - - let startSegment = uri.index(uri.startIndex, offsetBy: "data:".count) - self.headers = uri[startSegment.. Void - private var currentItemObserver: NSKeyValueObservation? - - init(player: AVPlayer, onStatusChanged: @escaping (AVPlayerItem.Status) -> Void) { - self.onStatusChanged = onStatusChanged - super.init() - - self.player = player - currentItemObserver = player.observe( - \AVPlayer.currentItem?.status, options: [.new], - changeHandler: { [weak self] _, change in - guard let self = self else { return } - - let status = change.newValue ?? .none - switch status { - case .readyToPlay: - Logger.module.debug("Player Item Status: Ready") - self.onStatusChanged(.readyToPlay) - case .failed: - Logger.module.debug("Player Item Status: Failed") - self.onStatusChanged(.failed) - case .unknown: - Logger.module.debug("Player Item Status: Unknown") - self.onStatusChanged(.unknown) - case .none: - Logger.module.debug("Player Item Status: None") - self.onStatusChanged(.unknown) - @unknown default: - assertionFailure("Unknown Switch Case for AVPlayerItemStatus") - } - }) - } -} diff --git a/Sources/Brave/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayer.swift b/Sources/Brave/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayer.swift index 23139986c02..02b5d4f9c9b 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayer.swift +++ b/Sources/Brave/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayer.swift @@ -9,7 +9,7 @@ import BraveShared import Shared import AVKit import AVFoundation - +import Playlist import MediaPlayer protocol VideoViewDelegate: AnyObject { diff --git a/Sources/Brave/Frontend/Browser/Search/BraveSearchManager.swift b/Sources/Brave/Frontend/Browser/Search/BraveSearchManager.swift index 41f12d15ea6..5854b66c903 100644 --- a/Sources/Brave/Frontend/Browser/Search/BraveSearchManager.swift +++ b/Sources/Brave/Frontend/Browser/Search/BraveSearchManager.swift @@ -9,6 +9,7 @@ import Shared import BraveShared import WebKit import os.log +import UserAgent // A helper class to handle Brave Search fallback needs. class BraveSearchManager: NSObject { diff --git a/Sources/Brave/Frontend/Browser/Tab.swift b/Sources/Brave/Frontend/Browser/Tab.swift index d5c2ea18f4b..5e63a6d3b5d 100644 --- a/Sources/Brave/Frontend/Browser/Tab.swift +++ b/Sources/Brave/Frontend/Browser/Tab.swift @@ -13,6 +13,7 @@ import Data import os.log import BraveWallet import Favicon +import UserAgent protocol TabContentScriptLoader { static func loadUserScript(named: String) -> String? diff --git a/Sources/Brave/Frontend/ClientPreferences.swift b/Sources/Brave/Frontend/ClientPreferences.swift index 0ad5f569815..1faa2df4d42 100644 --- a/Sources/Brave/Frontend/ClientPreferences.swift +++ b/Sources/Brave/Frontend/ClientPreferences.swift @@ -53,9 +53,6 @@ extension Preferences { public static let nightModeEnabled = Option(key: "general.night-mode-enabled", default: false) /// Specifies whether the bookmark button is present on toolbar static let showBookmarkToolbarShortcut = Option(key: "general.show-bookmark-toolbar-shortcut", default: UIDevice.isIpad) - /// Sets Desktop UA for iPad by default (iOS 13+ & iPad only). - /// Do not read it directly, prefer to use `UserAgent.shouldUseDesktopMode` instead. - static let alwaysRequestDesktopSite = Option(key: "general.always-request-desktop-site", default: UIDevice.isIpad) /// Controls whether or not media should continue playing in the background static let mediaAutoBackgrounding = Option(key: "general.media-auto-backgrounding", default: false) /// Controls whether or not youtube videos should play with the highest quality by default @@ -201,43 +198,6 @@ extension Preferences { /// When cosmetic filters Scriptlets were last time updated on the device. static let lastCosmeticFiltersScripletsUpdate = Option(key: "last-cosmetic-filters-scriptlets-update", default: nil) } - - final public class Playlist { - /// The Option to show video list left or right side - static let listViewSide = Option(key: "playlist.listViewSide", default: PlayListSide.left.rawValue) - /// The count of how many times Add to Playlist URL-Bar onboarding has been shown - static let addToPlaylistURLBarOnboardingCount = Option(key: "playlist.addToPlaylistURLBarOnboardingCount", default: 0) - /// The last played item url - static let lastPlayedItemUrl = Option(key: "playlist.last.played.item.url", default: nil) - /// The last played item time - static let lastPlayedItemTime = Option(key: "playlist.last.played.item.time", default: 0.0) - /// Whether to play the video when controller loaded - static let firstLoadAutoPlay = Option(key: "playlist.firstLoadAutoPlay", default: false) - /// The Option to download video yes / no / only wi-fi - static let autoDownloadVideo = Option(key: "playlist.autoDownload", default: PlayListDownloadType.on.rawValue) - /// The Option to disable playlist MediaSource web-compatibility - static let webMediaSourceCompatibility = Option(key: "playlist.webMediaSourceCompatibility", default: UIDevice.isIpad) - /// The option to start the playback where user left-off - static let playbackLeftOff = Option(key: "playlist.playbackLeftOff", default: true) - /// The option to disable long-press-to-add-to-playlist gesture. - static let enableLongPressAddToPlaylist = - Option(key: "playlist.longPressAddToPlaylist", default: true) - /// The option to enable or disable the 3-dot menu badge for playlist - static let enablePlaylistMenuBadge = - Option(key: "playlist.enablePlaylistMenuBadge", default: true) - /// The option to enable or disable the URL-Bar button for playlist - static let enablePlaylistURLBarButton = - Option(key: "playlist.enablePlaylistURLBarButton", default: true) - /// The option to enable or disable the continue where left-off playback in CarPlay - static let enableCarPlayRestartPlayback = - Option(key: "playlist.enableCarPlayRestartPlayback", default: false) - /// The last time all playlist folders were synced - static let lastPlaylistFoldersSyncTime = - Option(key: "playlist.lastPlaylistFoldersSyncTime", default: nil) - /// Sync shared folders automatically preference - static let syncSharedFoldersAutomatically = - Option(key: "playlist.syncSharedFoldersAutomatically", default: true) - } final public class PrivacyReports { /// Used to track whether to prompt user to enable app notifications. diff --git a/Sources/Brave/Frontend/Settings/Features/PlaylistSettingsViewController.swift b/Sources/Brave/Frontend/Settings/Features/PlaylistSettingsViewController.swift index 4ed951504c0..d30440c5f65 100644 --- a/Sources/Brave/Frontend/Settings/Features/PlaylistSettingsViewController.swift +++ b/Sources/Brave/Frontend/Settings/Features/PlaylistSettingsViewController.swift @@ -9,14 +9,12 @@ import Shared import BraveShared import Preferences import BraveUI +import Playlist // MARK: - PlayListSide -enum PlayListSide: String, CaseIterable, RepresentableOptionType { - case left - case right - - var displayString: String { +extension PlayListSide: RepresentableOptionType { + public var displayString: String { switch self { case .left: return Strings.PlayList.playlistSidebarLocationOptionLeft @@ -28,12 +26,8 @@ enum PlayListSide: String, CaseIterable, RepresentableOptionType { // MARK: - PlayListDownloadType -enum PlayListDownloadType: String, CaseIterable, RepresentableOptionType { - case on - case off - case wifi - - var displayString: String { +extension PlayListDownloadType: RepresentableOptionType { + public var displayString: String { switch self { case .on: return Strings.PlayList.playlistAutoSaveOptionOn @@ -200,6 +194,7 @@ class PlaylistSettingsViewController: TableViewController { UIAlertAction( title: Strings.PlayList.playlistResetAlertTitle, style: .default, handler: { _ in + PlaylistCarplayManager.shared.destroyPiP() PlaylistManager.shared.deleteAllItems(cacheOnly: false) })) alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel, handler: nil)) diff --git a/Sources/Brave/Frontend/Settings/Features/ShieldsPrivacy/Clearables.swift b/Sources/Brave/Frontend/Settings/Features/ShieldsPrivacy/Clearables.swift index 1dd266415b1..99cdcaf7193 100644 --- a/Sources/Brave/Frontend/Settings/Features/ShieldsPrivacy/Clearables.swift +++ b/Sources/Brave/Frontend/Settings/Features/ShieldsPrivacy/Clearables.swift @@ -11,6 +11,7 @@ import BraveCore import BraveNews import Favicon import os.log +import Playlist // A base protocol for something that can be cleared. protocol Clearable { @@ -188,6 +189,7 @@ class PlayListCacheClearable: Clearable { } func clear() async throws { + PlaylistCarplayManager.shared.destroyPiP() PlaylistManager.shared.deleteAllItems(cacheOnly: true) // Backup in case there is folder corruption, so we delete the cache anyway @@ -210,6 +212,7 @@ class PlayListDataClearable: Clearable { } func clear() async throws { + PlaylistCarplayManager.shared.destroyPiP() PlaylistManager.shared.deleteAllItems(cacheOnly: false) // Backup in case there is folder corruption, so we delete the cache anyway diff --git a/Sources/Brave/Frontend/Settings/SettingsViewController.swift b/Sources/Brave/Frontend/Settings/SettingsViewController.swift index 09095ae6a30..043e7b8edd0 100644 --- a/Sources/Brave/Frontend/Settings/SettingsViewController.swift +++ b/Sources/Brave/Frontend/Settings/SettingsViewController.swift @@ -359,7 +359,7 @@ class SettingsViewController: TableViewController { general.rows.append( .boolRow( title: Strings.alwaysRequestDesktopSite, - option: Preferences.General.alwaysRequestDesktopSite, + option: Preferences.UserAgent.alwaysRequestDesktopSite, image: UIImage(braveSystemNamed: "leo.window.cursor")) ) } 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 bf50ef5fb06..a3b95a40444 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 @@ -10,6 +10,7 @@ import Data import Preferences import Shared import os.log +import Playlist enum PlaylistItemAddedState { case none diff --git a/Sources/Brave/Frontend/Browser/Playlist/BasicAuthCredentials.swift b/Sources/BraveShared/BasicAuthCredentials.swift similarity index 94% rename from Sources/Brave/Frontend/Browser/Playlist/BasicAuthCredentials.swift rename to Sources/BraveShared/BasicAuthCredentials.swift index d43c62cdde5..891c3437a63 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/BasicAuthCredentials.swift +++ b/Sources/BraveShared/BasicAuthCredentials.swift @@ -8,7 +8,7 @@ import UIKit import BraveCore import os.log -class BasicAuthCredentialsManager: NSObject, URLSessionDataDelegate { +public class BasicAuthCredentialsManager: NSObject, URLSessionDataDelegate { private static var credentials = [String: URLCredential]() // ["origin:port": credential] public static let validDomains: Set = [ @@ -21,11 +21,11 @@ class BasicAuthCredentialsManager: NSObject, URLSessionDataDelegate { "playlist.bravesoftware.com" ] - static func setCredential(origin: String, credential: URLCredential?) { + public static func setCredential(origin: String, credential: URLCredential?) { credentials[origin] = credential } - func urlSession( + public func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { @@ -87,7 +87,7 @@ class BasicAuthCredentialsManager: NSObject, URLSessionDataDelegate { return (.performDefaultHandling, nil) } - func urlSession( + public func urlSession( _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge diff --git a/Sources/BraveStrings/BraveStrings.swift b/Sources/BraveStrings/BraveStrings.swift index 7bf311151cc..bf3c843117d 100644 --- a/Sources/BraveStrings/BraveStrings.swift +++ b/Sources/BraveStrings/BraveStrings.swift @@ -1931,11 +1931,6 @@ extension Strings { } public struct PlaylistFolders { - public static let playlistSavedFolderTitle = - NSLocalizedString("playlistFolders.savedFolderTitle", tableName: "BraveShared", bundle: .module, - value: "Play Later", - comment: "The title of the default playlist folder") - public static let playlistUntitledFolderTitle = NSLocalizedString("playlistFolders.untitledFolderTitle", tableName: "BraveShared", bundle: .module, value: "Untitled Playlist", diff --git a/Sources/Brave/Frontend/Browser/Playlist/VideoPlayer/Extensions/MPRemoteCommandCenter+Combine.swift b/Sources/Playlist/MPRemoteCommandCenter+Combine.swift similarity index 100% rename from Sources/Brave/Frontend/Browser/Playlist/VideoPlayer/Extensions/MPRemoteCommandCenter+Combine.swift rename to Sources/Playlist/MPRemoteCommandCenter+Combine.swift diff --git a/Sources/Brave/Frontend/Browser/Playlist/VideoPlayer/MediaPlayer.swift b/Sources/Playlist/MediaPlayer.swift similarity index 95% rename from Sources/Brave/Frontend/Browser/Playlist/VideoPlayer/MediaPlayer.swift rename to Sources/Playlist/MediaPlayer.swift index 4cf7ef7128b..9fffe96fb43 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/VideoPlayer/MediaPlayer.swift +++ b/Sources/Playlist/MediaPlayer.swift @@ -10,14 +10,16 @@ import Combine import MediaPlayer import Shared import os.log +import Then +import UserAgent -enum MediaPlaybackError: Error { +public enum MediaPlaybackError: Error { case cancelled case cannotLoadAsset(status: AVKeyValueStatus) case other(Error) } -class MediaPlayer: NSObject { +public class MediaPlayer: NSObject { public enum RepeatMode: CaseIterable { case none case repeatOne @@ -37,8 +39,8 @@ class MediaPlayer: NSObject { private(set) public var supportedPlaybackRates = [1.0, 1.5, 2.0] private(set) public var pendingMediaItem: AVPlayerItem? private(set) public var pictureInPictureController: AVPictureInPictureController? - private(set) var repeatState: RepeatMode = .none - private(set) var shuffleState: ShuffleMode = .none + private(set) public var repeatState: RepeatMode = .none + private(set) public var shuffleState: ShuffleMode = .none private(set) var previousRate: Float = 0.0 public var isPlaying: Bool { @@ -75,7 +77,7 @@ class MediaPlayer: NSObject { playerLayer.superlayer != nil } - override init() { + override public init() { super.init() playerLayer.player = self.player @@ -120,12 +122,12 @@ class MediaPlayer: NSObject { UIApplication.shared.endReceivingRemoteControlEvents() } - func clear() { + public func clear() { player.replaceCurrentItem(with: nil) pendingMediaItem = nil } - func load(url: URL) async throws -> Bool { + public func load(url: URL) async throws -> Bool { try await load(asset: AVURLAsset(url: url, options: AVAsset.defaultOptions)) } @@ -134,7 +136,7 @@ class MediaPlayer: NSObject { /// If an existing item is loaded, you should seek to offset zero to restart playback. /// If a new item is loaded, you should call play to begin playback. /// Returns an error on failure. - func load(asset: AVURLAsset) async throws -> Bool { + public func load(asset: AVURLAsset) async throws -> Bool { // If the same asset is being loaded again. // Just play it. if let currentItem = player.currentItem, currentItem.asset.isKind(of: AVURLAsset.self) && player.status == .readyToPlay { @@ -154,7 +156,7 @@ class MediaPlayer: NSObject { return true // New Item loaded } - func play() { + public func play() { if !isPlaying { player.play() @@ -165,7 +167,7 @@ class MediaPlayer: NSObject { } } - func pause() { + public func pause() { if isPlaying { if #unavailable(iOS 16) { previousRate = player.rate @@ -175,7 +177,7 @@ class MediaPlayer: NSObject { } } - func stop() { + public func stop() { if isPlaying { if #unavailable(iOS 16) { previousRate = player.rate @@ -186,15 +188,15 @@ class MediaPlayer: NSObject { } } - func seekPreviousTrack() { + public func seekPreviousTrack() { previousTrackSubscriber.send(EventNotification(mediaPlayer: self, event: .previousTrack)) } - func seekNextTrack() { + public func seekNextTrack() { nextTrackSubscriber.send(EventNotification(mediaPlayer: self, event: .nextTrack)) } - func seekBackwards() { + public func seekBackwards() { if let currentItem = player.currentItem { let currentTime = currentItem.currentTime().seconds var seekTime = currentTime - seekInterval @@ -213,7 +215,7 @@ class MediaPlayer: NSObject { } } - func seekForwards() { + public func seekForwards() { if let currentItem = player.currentItem { let currentTime = currentItem.currentTime().seconds let seekTime = currentTime + seekInterval @@ -230,7 +232,7 @@ class MediaPlayer: NSObject { } } - func seek(to time: TimeInterval) { + public func seek(to time: TimeInterval) { if let currentItem = player.currentItem { var seekTime = time if seekTime < 0.0 { @@ -254,7 +256,7 @@ class MediaPlayer: NSObject { } } - func toggleRepeatMode() { + public func toggleRepeatMode() { let command = MPRemoteCommandCenter.shared().changeRepeatModeCommand switch repeatState { case .none: @@ -274,7 +276,7 @@ class MediaPlayer: NSObject { event: .changeRepeatMode)) } - func toggleShuffleMode() { + public func toggleShuffleMode() { let command = MPRemoteCommandCenter.shared().changeShuffleModeCommand switch shuffleState { case .none: @@ -291,7 +293,7 @@ class MediaPlayer: NSObject { event: .changeShuffleMode)) } - func toggleGravity() { + public func toggleGravity() { switch playerLayer.videoGravity { case .resize: playerLayer.videoGravity = .resizeAspect @@ -309,7 +311,7 @@ class MediaPlayer: NSObject { event: .playerGravityChanged)) } - func setPlaybackRate(rate: Float) { + public func setPlaybackRate(rate: Float) { if #available(iOS 16, *) { player.defaultRate = rate player.rate = rate @@ -325,12 +327,12 @@ class MediaPlayer: NSObject { } @discardableResult - func attachLayer() -> CALayer { + public func attachLayer() -> CALayer { playerLayer.player = player return playerLayer } - func addTimeObserver(interval: Int, onTick: @escaping (CMTime) -> Void) -> Any { + public func addTimeObserver(interval: Int, onTick: @escaping (CMTime) -> Void) -> Any { let interval = CMTimeMake(value: Int64(interval), timescale: 1000) return player.addPeriodicTimeObserver( forInterval: interval, queue: .main, @@ -374,7 +376,7 @@ class MediaPlayer: NSObject { } extension MediaPlayer { - enum Event { + public enum Event { case pause case play case stop @@ -394,9 +396,9 @@ extension MediaPlayer { case playerGravityChanged } - struct EventNotification { - let mediaPlayer: MediaPlayer - let event: Event + public struct EventNotification { + public let mediaPlayer: MediaPlayer + public let event: Event } public func publisher(for event: Event) -> AnyPublisher { @@ -681,7 +683,7 @@ extension AVPlayerItem { } /// Returns whether or not the assetTrack has audio tracks OR the asset has audio tracks - func isAudioTracksAvailable() -> Bool { + public func isAudioTracksAvailable() -> Bool { tracks.filter({ $0.assetTrack?.mediaType == .audio }).isEmpty == false } @@ -690,7 +692,7 @@ extension AVPlayerItem { /// We do this because for m3u8 HLS streams, /// tracks may not always be available and the particle effect will show even on videos.. /// It's best to assume this type of media is a video stream. - func isVideoTracksAvailable() -> Bool { + public func isVideoTracksAvailable() -> Bool { if !isReadyToPlay { return true } @@ -726,7 +728,7 @@ extension AVPlayerItem { extension AVAsset { /// Returns whether or not the asset has audio tracks - func isAudioTracksAvailable() -> Bool { + public func isAudioTracksAvailable() -> Bool { !tracks.filter({ $0.mediaType == .audio }).isEmpty } @@ -735,11 +737,11 @@ extension AVAsset { /// We do this because for m3u8 HLS streams, /// tracks may not always be available and the particle effect will show even on videos.. /// It's best to assume this type of media is a video stream. - func isVideoTracksAvailable() -> Bool { + public func isVideoTracksAvailable() -> Bool { !tracks.filter({ $0.mediaType == .video }).isEmpty } - static var defaultOptions: [String: Any] { + public static var defaultOptions: [String: Any] { let userAgent = UserAgent.shouldUseDesktopMode ? UserAgent.desktop : UserAgent.mobile var options: [String: Any] = [:] if #available(iOS 16, *) { diff --git a/Sources/Playlist/PlaylistAssetFetcher.swift b/Sources/Playlist/PlaylistAssetFetcher.swift new file mode 100644 index 00000000000..ed0ef3956ec --- /dev/null +++ b/Sources/Playlist/PlaylistAssetFetcher.swift @@ -0,0 +1,21 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import AVFoundation + +class PlaylistAssetFetcher { + let itemId: String + private let asset: AVURLAsset + + init(itemId: String, asset: AVURLAsset) { + self.itemId = itemId + self.asset = asset + } + + func cancelLoading() { + asset.cancelLoading() + } +} diff --git a/Sources/Playlist/PlaylistCacheLoader.swift b/Sources/Playlist/PlaylistCacheLoader.swift new file mode 100644 index 00000000000..001a6f3251a --- /dev/null +++ b/Sources/Playlist/PlaylistCacheLoader.swift @@ -0,0 +1,25 @@ +// Copyright 2021 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import AVFoundation +import WebKit +import MobileCoreServices +import Data +import Shared +import BraveShields +import Preferences +import BraveCore +import Storage +import os.log + +public protocol PlaylistWebLoaderFactory { + func makeWebLoader() -> any PlaylistWebLoader +} + +public protocol PlaylistWebLoader: UIView { + func load(url: URL) async -> PlaylistInfo? + func stop() +} diff --git a/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistDownloadManager.swift b/Sources/Playlist/PlaylistDownloadManager.swift similarity index 97% rename from Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistDownloadManager.swift rename to Sources/Playlist/PlaylistDownloadManager.swift index 55ac2951bd2..67b922a5866 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistDownloadManager.swift +++ b/Sources/Playlist/PlaylistDownloadManager.swift @@ -8,6 +8,8 @@ import AVFoundation import Shared import Data import os.log +import BraveShared +import UserAgent protocol PlaylistDownloadManagerDelegate: AnyObject { func onDownloadProgressUpdate(id: String, percentComplete: Double) @@ -41,7 +43,7 @@ public class PlaylistDownloadManager: PlaylistStreamDownloadManagerDelegate { private var didRestoreSession = false weak var delegate: PlaylistDownloadManagerDelegate? - static var playlistDirectory: URL? { + public static var playlistDirectory: URL? { FileManager.default.getOrCreateFolder( name: "Playlist", excludeFromBackups: true, @@ -194,10 +196,19 @@ public class PlaylistDownloadManager: PlaylistStreamDownloadManagerDelegate { } fileprivate static func uniqueDownloadPathForFilename(_ filename: String) throws -> URL? { - let filename = HTTPDownload.stripUnicode(fromFilename: filename) + let filename = Self.stripUnicode(fromFilename: filename) let playlistDirectory = PlaylistDownloadManager.playlistDirectory return try playlistDirectory?.uniquePathForFilename(filename) } + + // Used to avoid name spoofing using Unicode RTL char to change file extension + private static func stripUnicode(fromFilename string: String) -> String { + let validFilenameSet = CharacterSet(charactersIn: ":/") + .union(.newlines) + .union(.controlCharacters) + .union(.illegalCharacters) + return string.components(separatedBy: validFilenameSet).joined() + } } private class PlaylistHLSDownloadManager: NSObject, AVAssetDownloadDelegate { @@ -211,7 +222,7 @@ private class PlaylistHLSDownloadManager: NSObject, AVAssetDownloadDelegate { func restoreSession(_ session: AVAssetDownloadURLSession, completion: @escaping () -> Void) { session.getAllTasks { [weak self] tasks in defer { - ensureMainThread { + DispatchQueue.main.async { completion() } } @@ -435,7 +446,7 @@ private class PlaylistFileDownloadManager: NSObject, URLSessionDownloadDelegate func restoreSession(_ session: URLSession, completion: @escaping () -> Void) { session.getAllTasks { [weak self] tasks in defer { - ensureMainThread { + DispatchQueue.main.async { completion() } } @@ -447,7 +458,7 @@ private class PlaylistFileDownloadManager: NSObject, URLSessionDownloadDelegate continue } - ensureMainThread { + DispatchQueue.main.async { if task.state != .completed, let item = PlaylistItem.getItem(uuid: itemId), let assetUrl = URL(string: item.mediaSrc) { diff --git a/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistManager.swift b/Sources/Playlist/PlaylistManager.swift similarity index 88% rename from Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistManager.swift rename to Sources/Playlist/PlaylistManager.swift index 77e58b0c43b..85e393eac0b 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Managers & Cache/PlaylistManager.swift +++ b/Sources/Playlist/PlaylistManager.swift @@ -7,7 +7,7 @@ import Foundation import AVFoundation import Combine import CoreData - +import UIKit import Shared import Data import Preferences @@ -23,7 +23,7 @@ public class PlaylistManager: NSObject { private var _playbackTask: Task? - var playbackTask: Task? { + public var playbackTask: Task? { get { _playbackTask } @@ -73,7 +73,7 @@ public class PlaylistManager: NSObject { deleteUserManagedAssets() } - var currentFolder: PlaylistFolder? { + public var currentFolder: PlaylistFolder? { didSet { frc.delegate = nil @@ -92,73 +92,73 @@ public class PlaylistManager: NSObject { } } - var onFolderRemovedOrUpdated: AnyPublisher { + public var onFolderRemovedOrUpdated: AnyPublisher { onFolderDeleted.eraseToAnyPublisher() } - var contentWillChange: AnyPublisher { + public var contentWillChange: AnyPublisher { onContentWillChange.eraseToAnyPublisher() } - var contentDidChange: AnyPublisher { + public var contentDidChange: AnyPublisher { onContentDidChange.eraseToAnyPublisher() } - var objectDidChange: AnyPublisher<(object: Any, indexPath: IndexPath?, type: NSFetchedResultsChangeType, newIndexPath: IndexPath?), Never> { + public var objectDidChange: AnyPublisher<(object: Any, indexPath: IndexPath?, type: NSFetchedResultsChangeType, newIndexPath: IndexPath?), Never> { onObjectChange.eraseToAnyPublisher() } - var downloadProgressUpdated: AnyPublisher<(id: String, percentComplete: Double), Never> { + public var downloadProgressUpdated: AnyPublisher<(id: String, percentComplete: Double), Never> { onDownloadProgressUpdate.eraseToAnyPublisher() } - var downloadStateChanged: AnyPublisher<(id: String, state: PlaylistDownloadManager.DownloadState, displayName: String?, error: Error?), Never> { + public var downloadStateChanged: AnyPublisher<(id: String, state: PlaylistDownloadManager.DownloadState, displayName: String?, error: Error?), Never> { onDownloadStateChanged.eraseToAnyPublisher() } - var onCurrentFolderDidChange: AnyPublisher<(), Never> { + public var onCurrentFolderDidChange: AnyPublisher<(), Never> { onCurrentFolderChanged.eraseToAnyPublisher() } - var allItems: [PlaylistInfo] { + public var allItems: [PlaylistInfo] { frc.fetchedObjects?.map({ PlaylistInfo(item: $0) }) ?? [] } - var numberOfAssets: Int { + public var numberOfAssets: Int { frc.fetchedObjects?.count ?? 0 } - var fetchedObjects: [PlaylistItem] { + public var fetchedObjects: [PlaylistItem] { frc.fetchedObjects ?? [] } - func updateLastPlayed(item: PlaylistInfo, playTime: Double) { + public func updateLastPlayed(item: PlaylistInfo, playTime: Double) { let lastPlayedTime = Preferences.Playlist.playbackLeftOff.value ? playTime : 0.0 Preferences.Playlist.lastPlayedItemUrl.value = item.pageSrc PlaylistItem.updateLastPlayed(itemId: item.tagId, pageSrc: item.pageSrc, lastPlayedOffset: lastPlayedTime) } - func itemAtIndex(_ index: Int) -> PlaylistInfo? { + public func itemAtIndex(_ index: Int) -> PlaylistInfo? { if index >= 0 && index < numberOfAssets { return PlaylistInfo(item: frc.object(at: IndexPath(row: index, section: 0))) } return nil } - func assetAtIndex(_ index: Int) -> AVURLAsset? { + public func assetAtIndex(_ index: Int) -> AVURLAsset? { if let item = itemAtIndex(index) { return asset(for: item.tagId, mediaSrc: item.src) } return nil } - func index(of itemId: String) -> Int? { + public func index(of itemId: String) -> Int? { frc.fetchedObjects?.firstIndex(where: { $0.uuid == itemId }) } - func reorderItems(from sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath, completion: (() -> Void)?) { + public func reorderItems(from sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath, completion: (() -> Void)?) { guard var objects = frc.fetchedObjects else { - ensureMainThread { + DispatchQueue.main.async { completion?() } return @@ -166,7 +166,7 @@ public class PlaylistManager: NSObject { frc.managedObjectContext.perform { [weak self] in defer { - ensureMainThread { + DispatchQueue.main.async { completion?() } } @@ -189,7 +189,7 @@ public class PlaylistManager: NSObject { } } - func state(for itemId: String) -> PlaylistDownloadManager.DownloadState { + public func state(for itemId: String) -> PlaylistDownloadManager.DownloadState { if downloadManager.downloadTask(for: itemId) != nil { return .inProgress } @@ -203,7 +203,7 @@ public class PlaylistManager: NSObject { return .invalid } - func sizeOfDownloadedItem(for itemId: String) -> String? { + public func sizeOfDownloadedItem(for itemId: String) -> String? { var isDirectory: ObjCBool = false if let asset = downloadManager.localAsset(for: itemId), FileManager.default.fileExists(atPath: asset.url.path, isDirectory: &isDirectory) { @@ -243,7 +243,7 @@ public class PlaylistManager: NSObject { return nil } - func reloadData() { + public func reloadData() { do { try frc.performFetch() } catch { @@ -261,18 +261,18 @@ public class PlaylistManager: NSObject { public func setupPlaylistFolder() { if let savedFolder = PlaylistFolder.getFolder(uuid: PlaylistFolder.savedFolderUUID) { - if savedFolder.title != Strings.PlaylistFolders.playlistSavedFolderTitle { + if savedFolder.title != Strings.Playlist.defaultPlaylistTitle { // This title may change so we should update it - savedFolder.title = Strings.PlaylistFolders.playlistSavedFolderTitle + savedFolder.title = Strings.Playlist.defaultPlaylistTitle } } else { - PlaylistFolder.addFolder(title: Strings.PlaylistFolders.playlistSavedFolderTitle, uuid: PlaylistFolder.savedFolderUUID) { uuid in + PlaylistFolder.addFolder(title: Strings.Playlist.defaultPlaylistTitle, uuid: PlaylistFolder.savedFolderUUID) { uuid in Logger.module.debug("Created Playlist Folder: \(uuid)") } } } - func download(item: PlaylistInfo) { + public func download(item: PlaylistInfo) { guard downloadManager.downloadTask(for: item.tagId) == nil, let assetUrl = URL(string: item.src) else { return } Task { let mimeType = await PlaylistMediaStreamer.getMimeType(assetUrl) @@ -290,11 +290,11 @@ public class PlaylistManager: NSObject { } } - func cancelDownload(itemId: String) { + public func cancelDownload(itemId: String) { downloadManager.cancelDownload(itemId: itemId) } - func delete(folder: PlaylistFolder, _ completion: ((_ success: Bool) -> Void)? = nil) { + public func delete(folder: PlaylistFolder, _ completion: ((_ success: Bool) -> Void)? = nil) { var success = true var itemsToDelete = [PlaylistInfo]() @@ -353,7 +353,7 @@ public class PlaylistManager: NSObject { } @discardableResult - func delete(item: PlaylistInfo) -> Bool { + public func delete(item: PlaylistInfo) -> Bool { cancelDownload(itemId: item.tagId) if let index = assetInformation.firstIndex(where: { $0.itemId == item.tagId }) { @@ -379,7 +379,7 @@ public class PlaylistManager: NSObject { } @discardableResult - func deleteCache(item: PlaylistInfo) -> Bool { + public func deleteCache(item: PlaylistInfo) -> Bool { cancelDownload(itemId: item.tagId) if let cacheItem = PlaylistItem.getItem(uuid: item.tagId), @@ -403,15 +403,7 @@ public class PlaylistManager: NSObject { return true } - func deleteAllItems(cacheOnly: Bool) { - // This is the only way to have the system kill picture in picture as the restoration controller is deallocated - // And that means the video is deallocated, its AudioSession is stopped, and the Picture-In-Picture controller is deallocated. - // This is because `AVPictureInPictureController` is NOT a view controller and there is no way to dismiss it - // other than to deallocate the restoration controller. - // We could also call `AVPictureInPictureController.stopPictureInPicture` BUT we'd still have to deallocate all resources. - // At least this way, we deallocate both AND pip is stopped in the destructor of `PlaylistViewController->ListController` - PlaylistCarplayManager.shared.playlistController = nil - + public func deleteAllItems(cacheOnly: Bool) { guard let playlistItems = frc.fetchedObjects else { Logger.module.error("An error occured while fetching Playlist Objects") return @@ -483,7 +475,7 @@ public class PlaylistManager: NSObject { } } - func autoDownload(item: PlaylistInfo) { + public func autoDownload(item: PlaylistInfo) { guard let downloadType = PlayListDownloadType(rawValue: Preferences.Playlist.autoDownloadVideo.value) else { return } @@ -500,7 +492,7 @@ public class PlaylistManager: NSObject { } } - func isDiskSpaceEncumbered() -> Bool { + public func isDiskSpaceEncumbered() -> Bool { let freeSpace = availableDiskSpace() ?? 0 let totalSpace = totalDiskSpace() ?? 0 let usedSpace = totalSpace - freeSpace @@ -570,7 +562,7 @@ extension PlaylistManager: NSFetchedResultsControllerDelegate { } extension PlaylistManager { - func getAssetDuration(item: PlaylistInfo, _ completion: @escaping (TimeInterval?) -> Void) { + public func getAssetDuration(item: PlaylistInfo, _ completion: @escaping (TimeInterval?) -> Void) { if assetInformation.contains(where: { $0.itemId == item.tagId }) { completion(nil) return @@ -682,7 +674,7 @@ extension PlaylistManager { if trackStatus == .cancelled || durationStatus == .cancelled { Logger.module.error("Asset Duration Fetch Cancelled") - ensureMainThread { + DispatchQueue.main.async { completion(nil) } return @@ -693,13 +685,13 @@ extension PlaylistManager { // Media item is expired.. permission is denied Logger.module.debug("Playlist Media Item Expired: \(item.pageSrc)") - ensureMainThread { + DispatchQueue.main.async { completion(nil) } } else { Logger.module.error("An unknown error occurred while attempting to fetch track and duration information: \(error.localizedDescription)") - ensureMainThread { + DispatchQueue.main.async { completion(nil) } } @@ -718,7 +710,7 @@ extension PlaylistManager { duration = asset.duration } - ensureMainThread { + DispatchQueue.main.async { if duration.isIndefinite { completion(TimeInterval.infinity) } else if abs(duration.seconds.distance(to: 0.0)) > tolerance { @@ -756,7 +748,7 @@ extension PlaylistManager { extension PlaylistManager { @MainActor - static func syncSharedFolder(sharedFolderUrl: String) async throws { + public static func syncSharedFolder(sharedFolderUrl: String) async throws { guard let folder = PlaylistFolder.getSharedFolder(sharedFolderUrl: sharedFolderUrl), let folderId = folder.uuid else { return @@ -780,7 +772,7 @@ extension PlaylistManager { } @MainActor - static func syncSharedFolders() async throws { + public static func syncSharedFolders() async throws { let folderURLs = PlaylistFolder.getSharedFolders().compactMap({ $0.sharedFolderUrl }) await withTaskGroup(of: Void.self) { group in folderURLs.forEach { url in diff --git a/Sources/Brave/Frontend/Browser/Playlist/Utilities/PlaylistMediaStreamer.swift b/Sources/Playlist/PlaylistMediaStreamer.swift similarity index 84% rename from Sources/Brave/Frontend/Browser/Playlist/Utilities/PlaylistMediaStreamer.swift rename to Sources/Playlist/PlaylistMediaStreamer.swift index d03d29a1444..50eb11c9373 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/Utilities/PlaylistMediaStreamer.swift +++ b/Sources/Playlist/PlaylistMediaStreamer.swift @@ -12,13 +12,15 @@ import MediaPlayer import Shared import Storage import os.log +import UserAgent -class PlaylistMediaStreamer { +public class PlaylistMediaStreamer { private weak var playerView: UIView? private weak var certStore: CertStore? - private var webLoader: PlaylistWebLoader? + private var webLoader: (any PlaylistWebLoader)? + private var webLoaderFactory: any PlaylistWebLoaderFactory - enum PlaybackError: Error { + public enum PlaybackError: Error { case none case cancelled case expired @@ -26,12 +28,13 @@ class PlaylistMediaStreamer { case other(Error) } - init(playerView: UIView) { + public init(playerView: UIView, webLoaderFactory: PlaylistWebLoaderFactory) { self.playerView = playerView + self.webLoaderFactory = webLoaderFactory } @MainActor - func loadMediaStreamingAsset(_ item: PlaylistInfo) async throws -> PlaylistInfo { + public func loadMediaStreamingAsset(_ item: PlaylistInfo) async throws -> PlaylistInfo { // 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. @@ -58,7 +61,7 @@ class PlaylistMediaStreamer { return item } - static func loadAssetPlayability(asset: AVURLAsset) async -> Bool { + public static func loadAssetPlayability(asset: AVURLAsset) async -> Bool { let isAssetPlayable = { () -> Bool in let status = asset.status(of: .isPlayable) if case .loaded(let value) = status { @@ -95,20 +98,20 @@ class PlaylistMediaStreamer { private func streamingFallback(_ item: PlaylistInfo) async throws -> PlaylistInfo { // Fallback to web stream try await withTaskCancellationHandler { @MainActor in - self.webLoader = PlaylistWebLoader().then { - // If we don't do this, youtube shows ads 100% of the time. - // It's some weird race-condition in WKWebView where the content blockers may not load until - // The WebView is visible! - self.playerView?.insertSubview($0, at: 0) - } + let webLoader = self.webLoaderFactory.makeWebLoader() + // If we don't do this, youtube shows ads 100% of the time. + // It's some weird race-condition in WKWebView where the content blockers may not load until + // The WebView is visible! + self.playerView?.insertSubview(webLoader, at: 0) + self.webLoader = webLoader guard let url = URL(string: item.pageSrc) else { throw PlaybackError.cannotLoadMedia } - let newItem = await webLoader?.load(url: url) - webLoader?.removeFromSuperview() - webLoader = nil + let newItem = await webLoader.load(url: url) + webLoader.removeFromSuperview() + self.webLoader = nil guard let newItem = newItem, URL(string: newItem.src) != nil else { throw PlaybackError.cannotLoadMedia @@ -134,7 +137,7 @@ class PlaylistMediaStreamer { } Task { - try await Task.sleep(seconds: 1) + try await Task.sleep(nanoseconds: NSEC_PER_SEC) PlaylistManager.shared.autoDownload(item: updatedItem) } return item @@ -159,7 +162,7 @@ class PlaylistMediaStreamer { // MARK: - Static - static func setNowPlayingInfo(_ item: PlaylistInfo, withPlayer player: MediaPlayer) { + public static func setNowPlayingInfo(_ item: PlaylistInfo, withPlayer player: MediaPlayer) { let mediaType: MPNowPlayingInfoMediaType = item.mimeType.contains("video") ? .video : .audio @@ -178,7 +181,7 @@ class PlaylistMediaStreamer { MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } - static func updateNowPlayingInfo(_ player: MediaPlayer) { + public static func updateNowPlayingInfo(_ player: MediaPlayer) { let mediaType: MPNowPlayingInfoMediaType = player.currentItem?.isVideoTracksAvailable() == true ? .video : .audio let duration = player.currentItem?.asset.duration.seconds ?? 0.0 @@ -193,11 +196,11 @@ class PlaylistMediaStreamer { MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } - static func clearNowPlayingInfo() { + public static func clearNowPlayingInfo() { MPNowPlayingInfoCenter.default().nowPlayingInfo = nil } - static func setNowPlayingMediaArtwork(image: UIImage?) { + public static func setNowPlayingMediaArtwork(image: UIImage?) { if let image = image { let artwork = MPMediaItemArtwork( boundsSize: image.size, @@ -212,7 +215,7 @@ class PlaylistMediaStreamer { } } - static func setNowPlayingMediaArtwork(artwork: MPMediaItemArtwork?) { + public static func setNowPlayingMediaArtwork(artwork: MPMediaItemArtwork?) { if let artwork = artwork { var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork @@ -220,7 +223,7 @@ class PlaylistMediaStreamer { } } - static func getMimeType(_ url: URL) async -> String? { + public static func getMimeType(_ url: URL) async -> String? { let request: URLRequest = { var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10.0) diff --git a/Sources/Playlist/PlaylistMimeTypeDetector.swift b/Sources/Playlist/PlaylistMimeTypeDetector.swift new file mode 100644 index 00000000000..135a95dd4bd --- /dev/null +++ b/Sources/Playlist/PlaylistMimeTypeDetector.swift @@ -0,0 +1,280 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import UniformTypeIdentifiers +import AVFoundation + +// IANA List of Audio types: https://www.iana.org/assignments/media-types/media-types.xhtml#audio +// IANA List of Video types: https://www.iana.org/assignments/media-types/media-types.xhtml#video +// APPLE List of UTI types: https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/UTIRef/Articles/System-DeclaredUniformTypeIdentifiers.html + +public class PlaylistMimeTypeDetector { + private(set) var mimeType: String? + private(set) var fileExtension: String? // When nil, assume `mpg` format. + + init(url: URL) { + let possibleFileExtension = url.pathExtension.lowercased() + if let supportedExtension = knownFileExtensions.first(where: { $0.lowercased() == possibleFileExtension }) { + self.fileExtension = supportedExtension + self.mimeType = mimeTypeMap.first(where: { $0.value == supportedExtension })?.key + } else if let fileExtension = PlaylistMimeTypeDetector.supportedAVAssetFileExtensions().first(where: { $0.lowercased() == possibleFileExtension }) { + self.fileExtension = fileExtension + self.mimeType = UTType(filenameExtension: fileExtension)?.preferredMIMEType + } + } + + init(mimeType: String) { + if let fileExtension = mimeTypeMap[mimeType.lowercased()] { + self.mimeType = mimeType + self.fileExtension = fileExtension + } else if let mimeType = PlaylistMimeTypeDetector.supportedAVAssetMimeTypes().first(where: { $0.lowercased() == mimeType.lowercased() }) { + self.mimeType = mimeType + self.fileExtension = UTType(mimeType: mimeType)?.preferredFilenameExtension + } + } + + init(data: Data) { + // Assume mpg by default. If it can't play, it will fail anyway.. + // AVPlayer REQUIRES that you give a file extension no matter what and will refuse to determine the extension for you without an + // AVResourceLoaderDelegate :S + + if findHeader(offset: 0, data: data, header: [0x1A, 0x45, 0xDF, 0xA3]) { + mimeType = "video/webm" + fileExtension = "webm" + return + } + + if findHeader(offset: 0, data: data, header: [0x1A, 0x45, 0xDF, 0xA3]) { + mimeType = "video/matroska" + fileExtension = "mkv" + return + } + + if findHeader(offset: 0, data: data, header: [0x4F, 0x67, 0x67, 0x53]) { + mimeType = "application/ogg" + fileExtension = "ogg" + return + } + + if findHeader(offset: 0, data: data, header: [0x52, 0x49, 0x46, 0x46]) && findHeader(offset: 8, data: data, header: [0x57, 0x41, 0x56, 0x45]) { + mimeType = "audio/x-wav" + fileExtension = "wav" + return + } + + if findHeader(offset: 0, data: data, header: [0xFF, 0xFB]) || findHeader(offset: 0, data: data, header: [0x49, 0x44, 0x33]) { + mimeType = "audio/mpeg" + fileExtension = "mp4" + return + } + + if findHeader(offset: 0, data: data, header: [0x66, 0x4C, 0x61, 0x43]) { + mimeType = "audio/flac" + fileExtension = "flac" + return + } + + if findHeader(offset: 4, data: data, header: [0x66, 0x74, 0x79, 0x70, 0x4D, 0x53, 0x4E, 0x56]) || findHeader(offset: 4, data: data, header: [0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D]) || findHeader(offset: 4, data: data, header: [0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32]) || findHeader(offset: 0, data: data, header: [0x33, 0x67, 0x70, 0x35]) { + mimeType = "video/mp4" + fileExtension = "mp4" + return + } + + if findHeader(offset: 0, data: data, header: [0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x56]) { + mimeType = "video/x-m4v" + fileExtension = "m4v" + return + } + + if findHeader(offset: 0, data: data, header: [0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70]) { + mimeType = "video/quicktime" + fileExtension = "mov" + return + } + + if findHeader(offset: 0, data: data, header: [0x52, 0x49, 0x46, 0x46]) && findHeader(offset: 8, data: data, header: [0x41, 0x56, 0x49]) { + mimeType = "video/x-msvideo" + fileExtension = "avi" + return + } + + if findHeader(offset: 0, data: data, header: [0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11, 0xA6, 0xD9]) { + mimeType = "video/x-ms-wmv" + fileExtension = "wmv" + return + } + + // Maybe + if findHeader(offset: 0, data: data, header: [0x00, 0x00, 0x01]) { + mimeType = "video/mpeg" + fileExtension = "mpg" + return + } + + if findHeader(offset: 0, data: data, header: [0x49, 0x44, 0x33]) || findHeader(offset: 0, data: data, header: [0xFF, 0xFB]) { + mimeType = "audio/mpeg" + fileExtension = "mp3" + return + } + + if findHeader(offset: 0, data: data, header: [0x4D, 0x34, 0x41, 0x20]) || findHeader(offset: 4, data: data, header: [0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x41]) { + mimeType = "audio/m4a" + fileExtension = "m4a" + return + } + + if findHeader(offset: 0, data: data, header: [0x23, 0x21, 0x41, 0x4D, 0x52, 0x0A]) { + mimeType = "audio/amr" + fileExtension = "amr" + return + } + + if findHeader(offset: 0, data: data, header: [0x46, 0x4C, 0x56, 0x01]) { + mimeType = "video/x-flv" + fileExtension = "flv" + return + } + + mimeType = "application/x-mpegURL" // application/vnd.apple.mpegurl + fileExtension = nil + } + + private func findHeader(offset: Int, data: Data, header: [UInt8]) -> Bool { + if offset < 0 || data.count < offset + header.count { + return false + } + + return [UInt8](data[offset..<(offset + header.count)]) == header + } + + /// Converts a list of AVFileType to a list of file extensions + private static func supportedAVAssetFileExtensions() -> [String] { + let types = AVURLAsset.audiovisualTypes() + return types.compactMap({ UTType($0.rawValue)?.preferredFilenameExtension }).filter({ !$0.isEmpty }) + } + + /// Converts a list of AVFileType to a list of mime-types + private static func supportedAVAssetMimeTypes() -> [String] { + let types = AVURLAsset.audiovisualTypes() + return types.compactMap({ UTType($0.rawValue)?.preferredMIMEType }).filter({ !$0.isEmpty }) + } + + private let knownFileExtensions = [ + "mov", + "qt", + "mp4", + "m4v", + "m4a", + "m4b", // DRM protected + "m4p", // DRM protected + "3gp", + "3gpp", + "sdv", + "3g2", + "3gp2", + "caf", + "wav", + "wave", + "bwf", + "aif", + "aiff", + "aifc", + "cdda", + "amr", + "mp3", + "au", + "snd", + "ac3", + "eac3", + "flac", + "aac", + "mp2", + "pls", + "avi", + "webm", + "ogg", + "mpg", + "mpg4", + "mpeg", + "mpg3", + "wma", + "wmv", + "swf", + "flv", + "mng", + "asx", + "asf", + "mkv", + ] + + private let mimeTypeMap = [ + "audio/x-wav": "wav", + "audio/vnd.wave": "wav", + "audio/aacp": "aacp", + "audio/mpeg3": "mp3", + "audio/mp3": "mp3", + "audio/x-caf": "caf", + "audio/mpeg": "mp3", // mpg3 + "audio/x-mpeg3": "mp3", + "audio/wav": "wav", + "audio/flac": "flac", + "audio/x-flac": "flac", + "audio/mp4": "mp4", + "audio/x-mpg": "mp3", // maybe mpg3 + "audio/scpls": "pls", + "audio/x-aiff": "aiff", + "audio/usac": "eac3", // Extended AC3 + "audio/x-mpeg": "mp3", + "audio/wave": "wav", + "audio/x-m4r": "m4r", + "audio/x-mp3": "mp3", + "audio/amr": "amr", + "audio/aiff": "aiff", + "audio/3gpp2": "3gp2", + "audio/aac": "aac", + "audio/mpg": "mp3", // mpg3 + "audio/mpegurl": "mpg", // actually .m3u8, .m3u HLS stream + "audio/x-m4b": "m4b", + "audio/x-m4p": "m4p", + "audio/x-scpls": "pls", + "audio/x-mpegurl": "mpg", // actually .m3u8, .m3u HLS stream + "audio/x-aac": "aac", + "audio/3gpp": "3gp", + "audio/basic": "au", + "audio/au": "au", + "audio/snd": "snd", + "audio/x-m4a": "m4a", + "audio/x-realaudio": "ra", + "video/3gpp2": "3gp2", + "video/quicktime": "mov", + "video/mp4": "mp4", + "video/mp4v": "mp4", + "video/mpg": "mpg", + "video/mpeg": "mpeg", + "video/x-mpg": "mpg", + "video/x-mpeg": "mpeg", + "video/avi": "avi", + "video/x-m4v": "m4v", + "video/mp2t": "ts", + "application/vnd.apple.mpegurl": "mpg", // actually .m3u8, .m3u HLS stream + "video/3gpp": "3gp", + "text/vtt": "vtt", // Subtitles format + "application/mp4": "mp4", + "application/x-mpegurl": "mpg", // actually .m3u8, .m3u HLS stream + "video/webm": "webm", + "application/ogg": "ogg", + "video/msvideo": "avi", + "video/x-msvideo": "avi", + "video/x-ms-wmv": "wmv", + "video/x-ms-wma": "wma", + "application/x-shockwave-flash": "swf", + "video/x-flv": "flv", + "video/x-mng": "mng", + "video/x-ms-asx": "asx", + "video/x-ms-asf": "asf", + "video/matroska": "mkv", + ] +} diff --git a/Sources/Playlist/PlaylistPreferences.swift b/Sources/Playlist/PlaylistPreferences.swift new file mode 100644 index 00000000000..271edf04f37 --- /dev/null +++ b/Sources/Playlist/PlaylistPreferences.swift @@ -0,0 +1,63 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import UIKit +import Preferences +import Shared + +// MARK: - PlayListSide + +public enum PlayListSide: String, CaseIterable { + case left + case right +} + +// MARK: - PlayListDownloadType + +public enum PlayListDownloadType: String, CaseIterable { + case on + case off + case wifi +} + +extension Preferences { + final public class Playlist { + /// The Option to show video list left or right side + public static let listViewSide = Option(key: "playlist.listViewSide", default: PlayListSide.left.rawValue) + /// The count of how many times Add to Playlist URL-Bar onboarding has been shown + public static let addToPlaylistURLBarOnboardingCount = Option(key: "playlist.addToPlaylistURLBarOnboardingCount", default: 0) + /// The last played item url + public static let lastPlayedItemUrl = Option(key: "playlist.last.played.item.url", default: nil) + /// The last played item time + public static let lastPlayedItemTime = Option(key: "playlist.last.played.item.time", default: 0.0) + /// Whether to play the video when controller loaded + public static let firstLoadAutoPlay = Option(key: "playlist.firstLoadAutoPlay", default: false) + /// The Option to download video yes / no / only wi-fi + public static let autoDownloadVideo = Option(key: "playlist.autoDownload", default: PlayListDownloadType.on.rawValue) + /// The Option to disable playlist MediaSource web-compatibility + public static let webMediaSourceCompatibility = Option(key: "playlist.webMediaSourceCompatibility", default: UIDevice.isIpad) + /// The option to start the playback where user left-off + public static let playbackLeftOff = Option(key: "playlist.playbackLeftOff", default: true) + /// The option to disable long-press-to-add-to-playlist gesture. + public static let enableLongPressAddToPlaylist = + Option(key: "playlist.longPressAddToPlaylist", default: true) + /// The option to enable or disable the 3-dot menu badge for playlist + public static let enablePlaylistMenuBadge = + Option(key: "playlist.enablePlaylistMenuBadge", default: true) + /// The option to enable or disable the URL-Bar button for playlist + public static let enablePlaylistURLBarButton = + Option(key: "playlist.enablePlaylistURLBarButton", default: true) + /// The option to enable or disable the continue where left-off playback in CarPlay + public static let enableCarPlayRestartPlayback = + Option(key: "playlist.enableCarPlayRestartPlayback", default: false) + /// The last time all playlist folders were synced + public static let lastPlaylistFoldersSyncTime = + Option(key: "playlist.lastPlaylistFoldersSyncTime", default: nil) + /// Sync shared folders automatically preference + public static let syncSharedFoldersAutomatically = + Option(key: "playlist.syncSharedFoldersAutomatically", default: true) + } +} diff --git a/Sources/Brave/Frontend/Browser/Playlist/PlaylistSharedFolder.swift b/Sources/Playlist/PlaylistSharedFolder.swift similarity index 83% rename from Sources/Brave/Frontend/Browser/Playlist/PlaylistSharedFolder.swift rename to Sources/Playlist/PlaylistSharedFolder.swift index 30af2f28407..12642a1ba13 100644 --- a/Sources/Brave/Frontend/Browser/Playlist/PlaylistSharedFolder.swift +++ b/Sources/Playlist/PlaylistSharedFolder.swift @@ -9,20 +9,21 @@ import Data import CoreData import CodableHelpers import OSLog +import BraveShared -struct PlaylistSharedFolderModel: Decodable { - let version: String - let folderId: String - let folderName: String - @URLString private(set) var folderImage: URL? - let creatorName: String - @URLString private(set) var creatorLink: URL? - let updateAt: String - fileprivate(set) var folderUrl: String? - fileprivate(set) var eTag: String? - fileprivate(set) var mediaItems: [PlaylistInfo] +public struct PlaylistSharedFolderModel: Decodable { + public let version: String + public let folderId: String + public let folderName: String + @URLString public private(set) var folderImage: URL? + public let creatorName: String + @URLString public private(set) var creatorLink: URL? + public let updateAt: String + public fileprivate(set) var folderUrl: String? + public fileprivate(set) var eTag: String? + public fileprivate(set) var mediaItems: [PlaylistInfo] - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) version = try container.decode(String.self, forKey: .version) folderId = try container.decode(String.self, forKey: .folderId) @@ -74,15 +75,15 @@ struct PlaylistSharedFolderModel: Decodable { } } -struct PlaylistSharedFolderNetwork { - enum Status: String, Error { +public struct PlaylistSharedFolderNetwork { + public enum Status: String, Error { case invalidURL case invalidResponse case cacheNotModified } @MainActor - static func fetchPlaylist(folderUrl: String) async throws -> PlaylistSharedFolderModel { + public static func fetchPlaylist(folderUrl: String) async throws -> PlaylistSharedFolderModel { guard let playlistURL = URL(string: folderUrl)?.appendingPathComponent("playlist").appendingPathExtension("json") else { throw Status.invalidURL } @@ -96,7 +97,7 @@ struct PlaylistSharedFolderNetwork { request.httpMethod = method headers.forEach({ request.setValue($0.value, forHTTPHeaderField: $0.key) }) - let (data, response) = try await NetworkManager(session: session).dataRequest(with: request) + let (data, response) = try await session.data(for: request) guard let response = response as? HTTPURLResponse, response.statusCode == 304 || response.statusCode >= 200 || response.statusCode <= 299 else { throw Status.invalidResponse @@ -121,7 +122,7 @@ struct PlaylistSharedFolderNetwork { } @MainActor - static func createInMemoryStorage(for model: PlaylistSharedFolderModel) async -> PlaylistFolder { + public static func createInMemoryStorage(for model: PlaylistSharedFolderModel) async -> PlaylistFolder { await withCheckedContinuation { continuation in // Create a local shared folder PlaylistFolder.addInMemoryFolder(title: model.folderName, @@ -140,7 +141,7 @@ struct PlaylistSharedFolderNetwork { } @MainActor - static func saveToDiskStorage(memoryFolder: PlaylistFolder) async -> String { + public static func saveToDiskStorage(memoryFolder: PlaylistFolder) async -> String { await withCheckedContinuation({ continuation in PlaylistFolder.saveInMemoryFolderToDisk(folder: memoryFolder) { folderId in PlaylistItem.saveInMemoryItemsToDisk(items: Array(memoryFolder.playlistItems ?? []), folderUUID: folderId) { @@ -150,16 +151,17 @@ struct PlaylistSharedFolderNetwork { }) } - static func fetchMediaItemInfo(item: PlaylistSharedFolderModel, viewForInvisibleWebView: UIView) async throws -> [PlaylistInfo] { + public static func fetchMediaItemInfo(item: PlaylistSharedFolderModel, viewForInvisibleWebView: UIView, webLoaderFactory: any PlaylistWebLoaderFactory) async throws -> [PlaylistInfo] { @Sendable @MainActor func fetchTask(item: PlaylistInfo) async throws -> PlaylistInfo { guard let url = URL(string: item.pageSrc) else { throw PlaylistMediaStreamer.PlaybackError.cannotLoadMedia } - let webLoader = PlaylistWebLoader().then { - viewForInvisibleWebView.insertSubview($0, at: 0) - } + let webLoader = webLoaderFactory.makeWebLoader() +// let webLoader = PlaylistWebLoader().then { + viewForInvisibleWebView.insertSubview(webLoader, at: 0) +// } guard let newItem = await webLoader.load(url: url) else { // Destroy the web loader. diff --git a/Sources/Playlist/PlaylistStrings.swift b/Sources/Playlist/PlaylistStrings.swift new file mode 100644 index 00000000000..886d02619a7 --- /dev/null +++ b/Sources/Playlist/PlaylistStrings.swift @@ -0,0 +1,18 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import Strings + +extension Strings { + public struct Playlist { + public static let defaultPlaylistTitle = NSLocalizedString( + "playlist.defaultPlaylistTitle", + bundle: .module, + value: "Play Later", + comment: "The title of the default playlist folder" + ) + } +} diff --git a/Sources/Playlist/Resources/de.lproj/Localizable.strings b/Sources/Playlist/Resources/de.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/en.lproj/Localizable.strings b/Sources/Playlist/Resources/en.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/es.lproj/Localizable.strings b/Sources/Playlist/Resources/es.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/fr.lproj/Localizable.strings b/Sources/Playlist/Resources/fr.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/id-ID.lproj/Localizable.strings b/Sources/Playlist/Resources/id-ID.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/it.lproj/Localizable.strings b/Sources/Playlist/Resources/it.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/ja.lproj/Localizable.strings b/Sources/Playlist/Resources/ja.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/ko-KR.lproj/Localizable.strings b/Sources/Playlist/Resources/ko-KR.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/ms.lproj/Localizable.strings b/Sources/Playlist/Resources/ms.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/nb.lproj/Localizable.strings b/Sources/Playlist/Resources/nb.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/pl.lproj/Localizable.strings b/Sources/Playlist/Resources/pl.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/pt-BR.lproj/Localizable.strings b/Sources/Playlist/Resources/pt-BR.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/ru.lproj/Localizable.strings b/Sources/Playlist/Resources/ru.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/sv.lproj/Localizable.strings b/Sources/Playlist/Resources/sv.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/tr.lproj/Localizable.strings b/Sources/Playlist/Resources/tr.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/uk.lproj/Localizable.strings b/Sources/Playlist/Resources/uk.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/zh-TW.lproj/Localizable.strings b/Sources/Playlist/Resources/zh-TW.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Playlist/Resources/zh.lproj/Localizable.strings b/Sources/Playlist/Resources/zh.lproj/Localizable.strings new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Brave/Frontend/Browser/UserAgent.swift b/Sources/UserAgent/UserAgent.swift similarity index 84% rename from Sources/Brave/Frontend/Browser/UserAgent.swift rename to Sources/UserAgent/UserAgent.swift index 8055454abc0..cd8a14a06f7 100644 --- a/Sources/Brave/Frontend/Browser/UserAgent.swift +++ b/Sources/UserAgent/UserAgent.swift @@ -3,7 +3,6 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/. import Foundation -import Shared import Preferences import UIKit @@ -16,10 +15,9 @@ public struct UserAgent { } public static var shouldUseDesktopMode: Bool { - if UIDevice.isIpad { - return Preferences.General.alwaysRequestDesktopSite.value + if UIDevice.current.userInterfaceIdiom == .pad { + return Preferences.UserAgent.alwaysRequestDesktopSite.value } - return false } } diff --git a/Sources/Shared/UserAgentBuilder.swift b/Sources/UserAgent/UserAgentBuilder.swift similarity index 100% rename from Sources/Shared/UserAgentBuilder.swift rename to Sources/UserAgent/UserAgentBuilder.swift diff --git a/Sources/UserAgent/UserAgentPreferences.swift b/Sources/UserAgent/UserAgentPreferences.swift new file mode 100644 index 00000000000..ef975a1f2e7 --- /dev/null +++ b/Sources/UserAgent/UserAgentPreferences.swift @@ -0,0 +1,16 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import Preferences +import UIKit + +extension Preferences { + public enum UserAgent { + /// Sets Desktop UA for iPad by default (iOS 13+ & iPad only). + /// Do not read it directly, prefer to use `UserAgent.shouldUseDesktopMode` instead. + public static let alwaysRequestDesktopSite = Option(key: "general.always-request-desktop-site", default: UIDevice.current.userInterfaceIdiom == .pad) + } +} diff --git a/Tests/SharedTests/UserAgentBuilderTests.swift b/Tests/UserAgentTests/UserAgentBuilderTests.swift similarity index 99% rename from Tests/SharedTests/UserAgentBuilderTests.swift rename to Tests/UserAgentTests/UserAgentBuilderTests.swift index ecb14e74ac5..240a8b4bd1c 100644 --- a/Tests/SharedTests/UserAgentBuilderTests.swift +++ b/Tests/UserAgentTests/UserAgentBuilderTests.swift @@ -4,7 +4,7 @@ import XCTest import WebKit -@testable import Shared +@testable import UserAgent private class MockPhoneDevice: UIDevice { override var userInterfaceIdiom: UIUserInterfaceIdiom { .phone } diff --git a/Tests/ClientTests/UserAgentTests.swift b/Tests/UserAgentTests/UserAgentTests.swift similarity index 98% rename from Tests/ClientTests/UserAgentTests.swift rename to Tests/UserAgentTests/UserAgentTests.swift index 624a5c77989..149850c3f2e 100644 --- a/Tests/ClientTests/UserAgentTests.swift +++ b/Tests/UserAgentTests/UserAgentTests.swift @@ -6,13 +6,14 @@ import XCTest import Shared import Preferences import WebKit +@testable import UserAgent @testable import Brave class UserAgentTests: XCTestCase { override func setUp() { super.setUp() - Preferences.General.alwaysRequestDesktopSite.reset() + Preferences.UserAgent.alwaysRequestDesktopSite.reset() } let desktopUARegex: (String) -> Bool = { ua in