From 6e9a23de98a54e37f7ed2c9e4acb18d35b920993 Mon Sep 17 00:00:00 2001 From: Brandon T Date: Thu, 26 Aug 2021 10:45:16 -0400 Subject: [PATCH 1/7] Implement Carplay! --- Client.xcodeproj/project.pbxproj | 138 +- .../Application/Delegates/AppDelegate.swift | 7 +- .../Shortcuts/ActivityShortcutManager.swift | 9 +- Client/Entitlements/Beta.entitlements | 2 + Client/Entitlements/Debug.entitlements | 2 + Client/Entitlements/Dev.entitlements | 2 + Client/Entitlements/Enterprise.entitlements | 2 + .../Release (AppStore).entitlements | 2 + Client/Entitlements/Release.entitlements | 2 + .../BrowserViewController+Menu.swift | 24 +- .../BrowserViewController+Playlist.swift | 2 +- .../Cache/HLSThumbnailGenerator.swift | 104 -- .../Browser/Playlist/Cells/PlaylistCell.swift | 6 +- .../PlaylistCarplayController.swift | 598 ++++++ .../PlaylistDetailViewController.swift | 163 ++ ...tListViewController+DragDropDelegate.swift | 100 + ...stViewController+TableViewDataSource.swift | 286 +++ ...ListViewController+TableViewDelegate.swift | 139 ++ .../PlaylistListViewController..swift | 622 +++++++ .../PlaylistViewController+AVDelegates.swift | 62 + .../Controllers/PlaylistViewController.swift | 834 +++++++++ .../PlaylistCacheLoader.swift | 0 .../PlaylistCarplayManager.swift | 130 ++ .../PlaylistDownloadManager.swift | 0 .../PlaylistManager.swift | 75 +- .../Onboarding & Toast}/PlaylistToast.swift | 0 .../Browser/Playlist/PlaylistMediaInfo.swift | 527 ------ .../Playlist/PlaylistViewController.swift | 1644 ----------------- .../DataURIParser.swift | 0 .../Utilities/PlaylistMediaStreamer.swift | 191 ++ .../Utilities/PlaylistStatusObserver.swift | 53 + .../Utilities/PlaylistThumbnailUtility.swift | 291 +++ .../MPRemoteCommandCenter+Combine.swift | 113 ++ .../Playlist/VideoPlayer/MediaPlayer.swift | 557 ++++++ .../{ => UI}/PlaylistParticleEmitter.swift | 0 .../Playlist/VideoPlayer/UI/VideoPlayer.swift | 507 +++++ .../{ => UI}/VideoPlayerControlsView.swift | 0 .../{ => UI}/VideoPlayerInfoBar.swift | 0 .../{ => UI}/VideoPlayerTrackbar.swift | 0 .../Playlist/VideoPlayer/VideoPlayer.swift | 786 -------- Client/Frontend/Browser/PlaylistHelper.swift | 2 +- Data/models/PlaylistItem.swift | 14 + 42 files changed, 4861 insertions(+), 3135 deletions(-) delete mode 100644 Client/Frontend/Browser/Playlist/Cache/HLSThumbnailGenerator.swift create mode 100644 Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift create mode 100644 Client/Frontend/Browser/Playlist/Controllers/PlaylistDetailViewController.swift create mode 100644 Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+DragDropDelegate.swift create mode 100644 Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift create mode 100644 Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDelegate.swift create mode 100644 Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController..swift create mode 100644 Client/Frontend/Browser/Playlist/Controllers/PlaylistViewController+AVDelegates.swift create mode 100644 Client/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift rename Client/Frontend/Browser/Playlist/{Cache => Managers & Cache}/PlaylistCacheLoader.swift (100%) create mode 100644 Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift rename Client/Frontend/Browser/Playlist/{Cache => Managers & Cache}/PlaylistDownloadManager.swift (100%) rename Client/Frontend/Browser/Playlist/{Cache => Managers & Cache}/PlaylistManager.swift (79%) rename Client/Frontend/Browser/{ => Playlist/Onboarding & Toast}/PlaylistToast.swift (100%) delete mode 100644 Client/Frontend/Browser/Playlist/PlaylistMediaInfo.swift delete mode 100644 Client/Frontend/Browser/Playlist/PlaylistViewController.swift rename Client/Frontend/Browser/Playlist/{VideoPlayer => Utilities}/DataURIParser.swift (100%) create mode 100644 Client/Frontend/Browser/Playlist/Utilities/PlaylistMediaStreamer.swift create mode 100644 Client/Frontend/Browser/Playlist/Utilities/PlaylistStatusObserver.swift create mode 100644 Client/Frontend/Browser/Playlist/Utilities/PlaylistThumbnailUtility.swift create mode 100644 Client/Frontend/Browser/Playlist/VideoPlayer/Extensions/MPRemoteCommandCenter+Combine.swift create mode 100644 Client/Frontend/Browser/Playlist/VideoPlayer/MediaPlayer.swift rename Client/Frontend/Browser/Playlist/VideoPlayer/{ => UI}/PlaylistParticleEmitter.swift (100%) create mode 100644 Client/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayer.swift rename Client/Frontend/Browser/Playlist/VideoPlayer/{ => UI}/VideoPlayerControlsView.swift (100%) rename Client/Frontend/Browser/Playlist/VideoPlayer/{ => UI}/VideoPlayerInfoBar.swift (100%) rename Client/Frontend/Browser/Playlist/VideoPlayer/{ => UI}/VideoPlayerTrackbar.swift (100%) delete mode 100644 Client/Frontend/Browser/Playlist/VideoPlayer/VideoPlayer.swift diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index 69957b2d42b..478e7f05be6 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -764,11 +764,8 @@ 5E4845C022DE381200372022 /* WindowRenderHelper.js in Resources */ = {isa = PBXBuildFile; fileRef = 5E4845BF22DE381200372022 /* WindowRenderHelper.js */; }; 5E4845C222DE3DF800372022 /* WindowRenderHelperScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E4845C122DE3DF800372022 /* WindowRenderHelperScript.swift */; }; 5E4E078324A0E4D700B01720 /* YoutubeAdblock.js in Resources */ = {isa = PBXBuildFile; fileRef = 5E4E078224A0E4D700B01720 /* YoutubeAdblock.js */; }; - 5E5E6E3A25BA03510035B6A0 /* PlaylistViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5E6E3925BA03510035B6A0 /* PlaylistViewController.swift */; }; 5E5E6E4625BA041E0035B6A0 /* PlaylistParticleEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5E6E4525BA041E0035B6A0 /* PlaylistParticleEmitter.swift */; }; 5E5E6E6325BA04730035B6A0 /* DataURIParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5E6E6225BA04730035B6A0 /* DataURIParser.swift */; }; - 5E5E6E6E25BA04AB0035B6A0 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5E6E6D25BA04AB0035B6A0 /* VideoPlayer.swift */; }; - 5E5E6F1525BA8DD70035B6A0 /* PlaylistMediaInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5E6F1425BA8DD70035B6A0 /* PlaylistMediaInfo.swift */; }; 5E5E6F2925BB61320035B6A0 /* Playlist.js in Resources */ = {isa = PBXBuildFile; fileRef = 5E5E6F2825BB61320035B6A0 /* Playlist.js */; }; 5E5E6F9825BB658A0035B6A0 /* PlaylistHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5E6F9725BB658A0035B6A0 /* PlaylistHelper.swift */; }; 5E612A8F234B7FCA007D12B5 /* OnboardingRewardsAgreementViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1D8C6A232BF95200BDE662 /* OnboardingRewardsAgreementViewController.swift */; }; @@ -781,7 +778,6 @@ 5E9288CA22DF864C007BE7A6 /* TabSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E9288C922DF864C007BE7A6 /* TabSessionTests.swift */; }; 5E99D01525E56BFB003F30B4 /* PlaylistManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E99C88A25C83BE3003F30B4 /* PlaylistManager.swift */; }; 5E99D02025E56C15003F30B4 /* PlaylistDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E99CDEF25DD8C81003F30B4 /* PlaylistDownloadManager.swift */; }; - 5E99D02B25E56C18003F30B4 /* HLSThumbnailGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E99CDAC25DC70E6003F30B4 /* HLSThumbnailGenerator.swift */; }; 5E99D02C25E56C1B003F30B4 /* PlaylistCacheLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5E6E7925BA156F0035B6A0 /* PlaylistCacheLoader.swift */; }; 5E9B28EB255047C80072E655 /* BookmarkModelStateObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E9B28EA255047C80072E655 /* BookmarkModelStateObserver.swift */; }; 5E9B28F7255048540072E655 /* BookmarkFetchers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E9B28F6255048540072E655 /* BookmarkFetchers.swift */; }; @@ -866,12 +862,27 @@ CA439A5925E6F29D00FE9150 /* VideoPlayerInfoBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA439A5825E6F29D00FE9150 /* VideoPlayerInfoBar.swift */; }; CA439A7625E8054A00FE9150 /* VideoPlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA439A7525E8054A00FE9150 /* VideoPlayerControlsView.swift */; }; CA439A9025E80EE400FE9150 /* VideoPlayerTrackbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA439A8F25E80EE400FE9150 /* VideoPlayerTrackbar.swift */; }; + CA550490269DED8F00C19917 /* MediaBackgrounding.js in Resources */ = {isa = PBXBuildFile; fileRef = CA55048F269DED8F00C19917 /* MediaBackgrounding.js */; }; CA752EA526CEABF8009356EF /* PlaylistToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA752EA426CEABF8009356EF /* PlaylistToast.swift */; }; CA752EB126CEBE52009356EF /* FontScaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9A22FF26A7370C00923D70 /* FontScaling.swift */; }; + CA8D5C0C26D7CD54009BF13D /* PlaylistCarplayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C0B26D7CD54009BF13D /* PlaylistCarplayController.swift */; }; + CA8D5C0E26D7CD8A009BF13D /* PlaylistDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C0D26D7CD8A009BF13D /* PlaylistDetailViewController.swift */; }; + CA8D5C1026D7CDAF009BF13D /* PlaylistListViewController+DragDropDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C0F26D7CDAF009BF13D /* PlaylistListViewController+DragDropDelegate.swift */; }; + CA8D5C1226D7CDCD009BF13D /* PlaylistListViewController+TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C1126D7CDCD009BF13D /* PlaylistListViewController+TableViewDataSource.swift */; }; + CA8D5C1426D7CDEB009BF13D /* PlaylistListViewController+TableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C1326D7CDEB009BF13D /* PlaylistListViewController+TableViewDelegate.swift */; }; + CA8D5C1626D7CE04009BF13D /* PlaylistListViewController..swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C1526D7CE04009BF13D /* PlaylistListViewController..swift */; }; + CA8D5C1826D7CE2E009BF13D /* PlaylistViewController+AVDelegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C1726D7CE2E009BF13D /* PlaylistViewController+AVDelegates.swift */; }; + CA8D5C1A26D7CE46009BF13D /* PlaylistViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C1926D7CE46009BF13D /* PlaylistViewController.swift */; }; + CA8D5C1C26D7CF04009BF13D /* PlaylistCarplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C1B26D7CF04009BF13D /* PlaylistCarplayManager.swift */; }; + CA8D5C2026D7D04C009BF13D /* PlaylistMediaStreamer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C1F26D7D04C009BF13D /* PlaylistMediaStreamer.swift */; }; + CA8D5C2226D7D067009BF13D /* PlaylistStatusObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C2126D7D067009BF13D /* PlaylistStatusObserver.swift */; }; + CA8D5C2426D7D07E009BF13D /* PlaylistThumbnailUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C2326D7D07E009BF13D /* PlaylistThumbnailUtility.swift */; }; + CA8D5C2726D7D0A8009BF13D /* MPRemoteCommandCenter+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C2626D7D0A8009BF13D /* MPRemoteCommandCenter+Combine.swift */; }; + CA8D5C2926D7D0C6009BF13D /* MediaPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C2826D7D0C6009BF13D /* MediaPlayer.swift */; }; + CA8D5C2C26D7D0F7009BF13D /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8D5C2B26D7D0F7009BF13D /* VideoPlayer.swift */; }; CA9A22FE26A71ADA00923D70 /* PlaylistPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9A22FD26A71ADA00923D70 /* PlaylistPopoverView.swift */; }; CA9A233426B97B4300923D70 /* PlaylistMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9A233326B97B4300923D70 /* PlaylistMenuButton.swift */; }; CA9A234126B97B8400923D70 /* MenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9A234026B97B8400923D70 /* MenuButton.swift */; }; - CA550490269DED8F00C19917 /* MediaBackgrounding.js in Resources */ = {isa = PBXBuildFile; fileRef = CA55048F269DED8F00C19917 /* MediaBackgrounding.js */; }; CAA10597266FE94700A0372D /* RecentSearchQRCodeScannerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1058B266FE94700A0372D /* RecentSearchQRCodeScannerController.swift */; }; CAB127632639F37C00BBFC75 /* RecentSearches.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB127622639F37C00BBFC75 /* RecentSearches.swift */; }; CAB127652639FB7000BBFC75 /* RecentSearchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB127642639FB7000BBFC75 /* RecentSearchCell.swift */; }; @@ -2458,12 +2469,9 @@ 5E4845BF22DE381200372022 /* WindowRenderHelper.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = WindowRenderHelper.js; sourceTree = ""; }; 5E4845C122DE3DF800372022 /* WindowRenderHelperScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowRenderHelperScript.swift; sourceTree = ""; }; 5E4E078224A0E4D700B01720 /* YoutubeAdblock.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = YoutubeAdblock.js; sourceTree = ""; }; - 5E5E6E3925BA03510035B6A0 /* PlaylistViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistViewController.swift; sourceTree = ""; }; 5E5E6E4525BA041E0035B6A0 /* PlaylistParticleEmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistParticleEmitter.swift; sourceTree = ""; }; 5E5E6E6225BA04730035B6A0 /* DataURIParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataURIParser.swift; sourceTree = ""; }; - 5E5E6E6D25BA04AB0035B6A0 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; 5E5E6E7925BA156F0035B6A0 /* PlaylistCacheLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistCacheLoader.swift; sourceTree = ""; }; - 5E5E6F1425BA8DD70035B6A0 /* PlaylistMediaInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistMediaInfo.swift; sourceTree = ""; }; 5E5E6F2825BB61320035B6A0 /* Playlist.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = Playlist.js; sourceTree = ""; }; 5E5E6F9725BB658A0035B6A0 /* PlaylistHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistHelper.swift; sourceTree = ""; }; 5E6683A823D61CF7005B3A6C /* NTPDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NTPDownloader.swift; sourceTree = ""; }; @@ -2474,7 +2482,6 @@ 5E8CD8E023D5E3D100548FC0 /* libarchive.2.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libarchive.2.tbd; path = usr/lib/libarchive.2.tbd; sourceTree = SDKROOT; }; 5E9288C922DF864C007BE7A6 /* TabSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSessionTests.swift; sourceTree = ""; }; 5E99C88A25C83BE3003F30B4 /* PlaylistManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistManager.swift; sourceTree = ""; }; - 5E99CDAC25DC70E6003F30B4 /* HLSThumbnailGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HLSThumbnailGenerator.swift; sourceTree = ""; }; 5E99CDEF25DD8C81003F30B4 /* PlaylistDownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistDownloadManager.swift; sourceTree = ""; }; 5E9B28EA255047C80072E655 /* BookmarkModelStateObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkModelStateObserver.swift; sourceTree = ""; }; 5E9B28F6255048540072E655 /* BookmarkFetchers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkFetchers.swift; sourceTree = ""; }; @@ -2555,12 +2562,27 @@ CA439A5825E6F29D00FE9150 /* VideoPlayerInfoBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerInfoBar.swift; sourceTree = ""; }; CA439A7525E8054A00FE9150 /* VideoPlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerControlsView.swift; sourceTree = ""; }; CA439A8F25E80EE400FE9150 /* VideoPlayerTrackbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerTrackbar.swift; sourceTree = ""; }; + CA55048F269DED8F00C19917 /* MediaBackgrounding.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = MediaBackgrounding.js; sourceTree = ""; }; CA752EA426CEABF8009356EF /* PlaylistToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistToast.swift; sourceTree = ""; }; + CA8D5C0B26D7CD54009BF13D /* PlaylistCarplayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistCarplayController.swift; sourceTree = ""; }; + CA8D5C0D26D7CD8A009BF13D /* PlaylistDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistDetailViewController.swift; sourceTree = ""; }; + CA8D5C0F26D7CDAF009BF13D /* PlaylistListViewController+DragDropDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlaylistListViewController+DragDropDelegate.swift"; sourceTree = ""; }; + CA8D5C1126D7CDCD009BF13D /* PlaylistListViewController+TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlaylistListViewController+TableViewDataSource.swift"; sourceTree = ""; }; + CA8D5C1326D7CDEB009BF13D /* PlaylistListViewController+TableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlaylistListViewController+TableViewDelegate.swift"; sourceTree = ""; }; + CA8D5C1526D7CE04009BF13D /* PlaylistListViewController..swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistListViewController..swift; sourceTree = ""; }; + CA8D5C1726D7CE2E009BF13D /* PlaylistViewController+AVDelegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlaylistViewController+AVDelegates.swift"; sourceTree = ""; }; + CA8D5C1926D7CE46009BF13D /* PlaylistViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistViewController.swift; sourceTree = ""; }; + CA8D5C1B26D7CF04009BF13D /* PlaylistCarplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistCarplayManager.swift; sourceTree = ""; }; + CA8D5C1F26D7D04C009BF13D /* PlaylistMediaStreamer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistMediaStreamer.swift; sourceTree = ""; }; + CA8D5C2126D7D067009BF13D /* PlaylistStatusObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistStatusObserver.swift; sourceTree = ""; }; + CA8D5C2326D7D07E009BF13D /* PlaylistThumbnailUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistThumbnailUtility.swift; sourceTree = ""; }; + CA8D5C2626D7D0A8009BF13D /* MPRemoteCommandCenter+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MPRemoteCommandCenter+Combine.swift"; sourceTree = ""; }; + CA8D5C2826D7D0C6009BF13D /* MediaPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayer.swift; sourceTree = ""; }; + CA8D5C2B26D7D0F7009BF13D /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; CA9A22FD26A71ADA00923D70 /* PlaylistPopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistPopoverView.swift; sourceTree = ""; }; CA9A22FF26A7370C00923D70 /* FontScaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontScaling.swift; sourceTree = ""; }; CA9A233326B97B4300923D70 /* PlaylistMenuButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistMenuButton.swift; sourceTree = ""; }; CA9A234026B97B8400923D70 /* MenuButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuButton.swift; sourceTree = ""; }; - CA55048F269DED8F00C19917 /* MediaBackgrounding.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = MediaBackgrounding.js; sourceTree = ""; }; CAA1058B266FE94700A0372D /* RecentSearchQRCodeScannerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentSearchQRCodeScannerController.swift; sourceTree = ""; }; CAB127622639F37C00BBFC75 /* RecentSearches.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSearches.swift; sourceTree = ""; }; CAB127642639FB7000BBFC75 /* RecentSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSearchCell.swift; sourceTree = ""; }; @@ -4807,11 +4829,12 @@ 5E5E6E3825BA03360035B6A0 /* Playlist */ = { isa = PBXGroup; children = ( + CA8D5C1E26D7D018009BF13D /* Utilities */, + CA8D5C1D26D7CFD6009BF13D /* Onboarding & Toast */, + CA8D5C0A26D7CD46009BF13D /* Controllers */, 5EC2C07125BF9880005EA984 /* Cells */, - 5E5E6E7825BA15610035B6A0 /* Cache */, + 5E5E6E7825BA15610035B6A0 /* Managers & Cache */, 5E5E6E4425BA04120035B6A0 /* VideoPlayer */, - 5E5E6E3925BA03510035B6A0 /* PlaylistViewController.swift */, - 5E5E6F1425BA8DD70035B6A0 /* PlaylistMediaInfo.swift */, 5E824A33260BC6CA00127F36 /* BrowserViewController+Playlist.swift */, CA9A22FD26A71ADA00923D70 /* PlaylistPopoverView.swift */, ); @@ -4821,25 +4844,22 @@ 5E5E6E4425BA04120035B6A0 /* VideoPlayer */ = { isa = PBXGroup; children = ( - CA439A5825E6F29D00FE9150 /* VideoPlayerInfoBar.swift */, - CA439A8F25E80EE400FE9150 /* VideoPlayerTrackbar.swift */, - CA439A7525E8054A00FE9150 /* VideoPlayerControlsView.swift */, - 5E5E6E6D25BA04AB0035B6A0 /* VideoPlayer.swift */, - 5E5E6E4525BA041E0035B6A0 /* PlaylistParticleEmitter.swift */, - 5E5E6E6225BA04730035B6A0 /* DataURIParser.swift */, + CA8D5C2A26D7D0E5009BF13D /* UI */, + CA8D5C2526D7D097009BF13D /* Extensions */, + CA8D5C2826D7D0C6009BF13D /* MediaPlayer.swift */, ); path = VideoPlayer; sourceTree = ""; }; - 5E5E6E7825BA15610035B6A0 /* Cache */ = { + 5E5E6E7825BA15610035B6A0 /* Managers & Cache */ = { isa = PBXGroup; children = ( + CA8D5C1B26D7CF04009BF13D /* PlaylistCarplayManager.swift */, 5E5E6E7925BA156F0035B6A0 /* PlaylistCacheLoader.swift */, 5E99C88A25C83BE3003F30B4 /* PlaylistManager.swift */, 5E99CDEF25DD8C81003F30B4 /* PlaylistDownloadManager.swift */, - 5E99CDAC25DC70E6003F30B4 /* HLSThumbnailGenerator.swift */, ); - path = Cache; + path = "Managers & Cache"; sourceTree = ""; }; 5EA84392234F803100076A91 /* Certificates */ = { @@ -4972,6 +4992,60 @@ path = BrowserViewController; sourceTree = ""; }; + CA8D5C0A26D7CD46009BF13D /* Controllers */ = { + isa = PBXGroup; + children = ( + CA8D5C0B26D7CD54009BF13D /* PlaylistCarplayController.swift */, + CA8D5C0D26D7CD8A009BF13D /* PlaylistDetailViewController.swift */, + CA8D5C0F26D7CDAF009BF13D /* PlaylistListViewController+DragDropDelegate.swift */, + CA8D5C1126D7CDCD009BF13D /* PlaylistListViewController+TableViewDataSource.swift */, + CA8D5C1326D7CDEB009BF13D /* PlaylistListViewController+TableViewDelegate.swift */, + CA8D5C1526D7CE04009BF13D /* PlaylistListViewController..swift */, + CA8D5C1726D7CE2E009BF13D /* PlaylistViewController+AVDelegates.swift */, + CA8D5C1926D7CE46009BF13D /* PlaylistViewController.swift */, + ); + path = Controllers; + sourceTree = ""; + }; + CA8D5C1D26D7CFD6009BF13D /* Onboarding & Toast */ = { + isa = PBXGroup; + children = ( + CA752EA426CEABF8009356EF /* PlaylistToast.swift */, + ); + path = "Onboarding & Toast"; + sourceTree = ""; + }; + CA8D5C1E26D7D018009BF13D /* Utilities */ = { + isa = PBXGroup; + children = ( + 5E5E6E6225BA04730035B6A0 /* DataURIParser.swift */, + CA8D5C1F26D7D04C009BF13D /* PlaylistMediaStreamer.swift */, + CA8D5C2126D7D067009BF13D /* PlaylistStatusObserver.swift */, + CA8D5C2326D7D07E009BF13D /* PlaylistThumbnailUtility.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + CA8D5C2526D7D097009BF13D /* Extensions */ = { + isa = PBXGroup; + children = ( + CA8D5C2626D7D0A8009BF13D /* MPRemoteCommandCenter+Combine.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + CA8D5C2A26D7D0E5009BF13D /* UI */ = { + isa = PBXGroup; + children = ( + 5E5E6E4525BA041E0035B6A0 /* PlaylistParticleEmitter.swift */, + CA8D5C2B26D7D0F7009BF13D /* VideoPlayer.swift */, + CA439A5825E6F29D00FE9150 /* VideoPlayerInfoBar.swift */, + CA439A8F25E80EE400FE9150 /* VideoPlayerTrackbar.swift */, + CA439A7525E8054A00FE9150 /* VideoPlayerControlsView.swift */, + ); + path = UI; + sourceTree = ""; + }; CAB127612639D02100BBFC75 /* Recent Search */ = { isa = PBXGroup; children = ( @@ -5200,7 +5274,6 @@ 4452CAEF255412800053EFE6 /* DefaultBrowserIntroCalloutViewController.swift */, 0A0A5ED025B1F080007B3E74 /* DefaultBrowserIntroManager.swift */, 0AE50869261C6F2E0099C6A3 /* BraveSearchHelper.swift */, - CA752EA426CEABF8009356EF /* PlaylistToast.swift */, ); indentWidth = 4; path = Browser; @@ -7052,8 +7125,8 @@ 0A1E843F2190A57F0042F782 /* SyncQRCodeView.swift in Sources */, 4422D56921BFFB7F00BF1855 /* prefilter.cc in Sources */, CAB127652639FB7000BBFC75 /* RecentSearchCell.swift in Sources */, - 5E5E6F1525BA8DD70035B6A0 /* PlaylistMediaInfo.swift in Sources */, 5E4845C222DE3DF800372022 /* WindowRenderHelperScript.swift in Sources */, + CA8D5C2C26D7D0F7009BF13D /* VideoPlayer.swift in Sources */, CAA10597266FE94700A0372D /* RecentSearchQRCodeScannerController.swift in Sources */, 27036F9825684F9F004EF6B6 /* AdsViewController.swift in Sources */, 27F94A3A21909A5900F4FADF /* SearchSuggestionsPromptView.swift in Sources */, @@ -7108,6 +7181,7 @@ 2FBCB169262784BF00F512D8 /* BrowserViewController+CoreMigration.swift in Sources */, D3C3696E1CC6B78800348A61 /* LocalRequestHelper.swift in Sources */, 27733E5826977EC40086799A /* PasscodeMigrationView.swift in Sources */, + CA8D5C2926D7D0C6009BF13D /* MediaPlayer.swift in Sources */, 2726637324981B600056CFE1 /* FeedSectionHeaderView.swift in Sources */, 27C461DE211B76500088A441 /* ShieldsView.swift in Sources */, 0A40450C25A47BD6009EC321 /* FrequencyQuery.swift in Sources */, @@ -7131,6 +7205,7 @@ 0A0A5ED125B1F080007B3E74 /* DefaultBrowserIntroManager.swift in Sources */, 442477FA268CB86F008A04D7 /* BraveSearchDebugMenuDetail.swift in Sources */, 27AD20CC26851DD100889AA7 /* BraveRewards.swift in Sources */, + CA8D5C1626D7CE04009BF13D /* PlaylistListViewController..swift in Sources */, 0A8ABE19247435E30062DA81 /* BraveVPNCommonUI.swift in Sources */, A13AC72520EC12360040D4BB /* Migration.swift in Sources */, 27201EFC24535C2F00C19DD1 /* NewTabPageFlowLayout.swift in Sources */, @@ -7189,7 +7264,6 @@ 0A8C69BE225E350300988715 /* IndentedImageTableViewCell.swift in Sources */, 0AE5C69124F0059D004CBC9B /* OnboardingPrivacyConsentViewController.swift in Sources */, 5E096180252B63F200F3AFBB /* BraveSyncAPI+Utilities.swift in Sources */, - 5E5E6E3A25BA03510035B6A0 /* PlaylistViewController.swift in Sources */, 0A1E843D2190A57F0042F782 /* SyncSettingsTableViewController.swift in Sources */, 4422D56C21BFFB7F00BF1855 /* bitstate.cc in Sources */, D314E7F71A37B98700426A76 /* BottomToolbarView.swift in Sources */, @@ -7223,7 +7297,6 @@ 27EA04C2249D4336004F5D2E /* FeedCardContent.swift in Sources */, 2755EAD3255329540033C43F /* BraveLedgerExtensions.swift in Sources */, 0BF1B7E31AC60DEA00A7B407 /* InsetButton.swift in Sources */, - 5E5E6E6E25BA04AB0035B6A0 /* VideoPlayer.swift in Sources */, D0C95EF6201A55A800E4E51C /* BrowserViewController+UIDropInteractionDelegate.swift in Sources */, 27036F9C25684F9F004EF6B6 /* AdContentButton.swift in Sources */, 27A1ABFF24855C1700344503 /* FeedGroupViews.swift in Sources */, @@ -7265,6 +7338,7 @@ 74E36D781B71323500D69DA1 /* SettingsContentViewController.swift in Sources */, 2F51767425E4024B00429692 /* PlaylistSettingsViewController.swift in Sources */, 0A1DF468244858CA00541FE4 /* VPNProductInfo.swift in Sources */, + CA8D5C0E26D7CD8A009BF13D /* PlaylistDetailViewController.swift in Sources */, 2F2C35A6261DF41100631310 /* CustomIntentHandler.swift in Sources */, 27B3DDD524AF98EA0006A7ED /* FeedItem.swift in Sources */, 448DC7D6268A5C3E00F4795C /* BraveSearchDebugMenu.swift in Sources */, @@ -7302,6 +7376,7 @@ 2FDF290825DEE265001E5C87 /* AddToPlaylistActivity.swift in Sources */, 273FCB9A25A7BC5500F279B5 /* BraveNewsDebugSettingsController.swift in Sources */, 39A359E41BFCCE94006B9E87 /* UserActivityHandler.swift in Sources */, + CA8D5C2026D7D04C009BF13D /* PlaylistMediaStreamer.swift in Sources */, 0AF6B1EA24A0EE19005417FC /* InstallVPNView.swift in Sources */, 27201F0424589B9800C19DD1 /* NewTabPageNotifications.swift in Sources */, E698FFDA1B4AADF40001F623 /* TabScrollController.swift in Sources */, @@ -7353,6 +7428,7 @@ 27829E092548AA8B007CF0B2 /* BraveRewardsView.swift in Sources */, D38A1BEE1A9FA2CA00F6A386 /* SiteTableViewController.swift in Sources */, E6D8D5E71B569D70009E5A58 /* BrowserTrayAnimators.swift in Sources */, + CA8D5C1426D7CDEB009BF13D /* PlaylistListViewController+TableViewDelegate.swift in Sources */, 4422D4B121BFFB7600BF1855 /* bloom.cc in Sources */, 4422D4FD21BFFB7600BF1855 /* dumpfile.cc in Sources */, 0A19365423508756002E2B81 /* LinkPreviewViewController.swift in Sources */, @@ -7401,6 +7477,8 @@ 44331DDA22552313007E3E93 /* LocationContainerView.swift in Sources */, 5E5E6E4625BA041E0035B6A0 /* PlaylistParticleEmitter.swift in Sources */, 2718780A216526240006036E /* PopupView.swift in Sources */, + CA8D5C0C26D7CD54009BF13D /* PlaylistCarplayController.swift in Sources */, + CA8D5C2726D7D0A8009BF13D /* MPRemoteCommandCenter+Combine.swift in Sources */, 4422D4C221BFFB7600BF1855 /* comparator.cc in Sources */, 0A1E84452190A57F0042F782 /* SyncPairWordsViewController.swift in Sources */, 2746D27524A2A9FE00E38852 /* RewardsInternalsContributionPublishersListController.swift in Sources */, @@ -7421,6 +7499,7 @@ D04D1B92209790B60074B35F /* Toast.swift in Sources */, D34DC8531A16C40C00D49B7B /* Profile.swift in Sources */, 27A1AC0924859D0900344503 /* FeedHeadlineViews.swift in Sources */, + CA8D5C2226D7D067009BF13D /* PlaylistStatusObserver.swift in Sources */, 0A93F1892264C72000A3571B /* BookmarkFormFieldsProtocol.swift in Sources */, 4422D4D821BFFB7600BF1855 /* block.cc in Sources */, F9B23EE523F613E8000EB3D8 /* PaymentRequestExtension.swift in Sources */, @@ -7429,6 +7508,7 @@ 5E5E6F9825BB658A0035B6A0 /* PlaylistHelper.swift in Sources */, 4422D55C21BFFB7F00BF1855 /* perl_groups.cc in Sources */, C4E3984C1D21F2FD004E89BA /* TabTrayButtonExtensions.swift in Sources */, + CA8D5C1826D7CE2E009BF13D /* PlaylistViewController+AVDelegates.swift in Sources */, 5D24AF8221BA459000F9506A /* BlocklistName.swift in Sources */, 277223762469E7290059A7EB /* LargeFaviconView.swift in Sources */, D3FEC38D1AC4B42F00494F45 /* AutocompleteTextField.swift in Sources */, @@ -7442,9 +7522,11 @@ 0A66550A23E9D9750047EF2A /* UserAgent.swift in Sources */, 3BF56D271CDBBE1F00AC4D75 /* SimpleToast.swift in Sources */, D31F95E91AC226CB005C9F3B /* ScreenshotHelper.swift in Sources */, + CA8D5C1226D7CDCD009BF13D /* PlaylistListViewController+TableViewDataSource.swift in Sources */, D3968F251A38FE8500CEFD3B /* TabManager.swift in Sources */, 5E6683A923D61CF7005B3A6C /* NTPDownloader.swift in Sources */, 2816F0001B33E05400522243 /* UIConstants.swift in Sources */, + CA8D5C1026D7CDAF009BF13D /* PlaylistListViewController+DragDropDelegate.swift in Sources */, 4422D56521BFFB7F00BF1855 /* filtered_re2.cc in Sources */, 277223722469B4FB0059A7EB /* FaviconFetcher.swift in Sources */, 274398F524E71D8700E79605 /* FailableDecodable.swift in Sources */, @@ -7485,6 +7567,7 @@ 2F676F6F260BA1B00048A1DB /* BlockingSummaryDataSource.swift in Sources */, 27CFB71424E1F67B008DFC8C /* Observable.swift in Sources */, 27DCB9EE263765430067EF4A /* TabTrayToolbar.swift in Sources */, + CA8D5C1A26D7CE46009BF13D /* PlaylistViewController.swift in Sources */, A134B88A20DA98BB00A581D0 /* ClientPreferences.swift in Sources */, D3BE7B261B054D4400641031 /* main.swift in Sources */, 0A4BEF5D221AF1910005551A /* AdblockResourceDownloader.swift in Sources */, @@ -7501,6 +7584,7 @@ 274398E224E4827800E79605 /* FeedCard.swift in Sources */, 4422D4BF21BFFB7600BF1855 /* logging.cc in Sources */, 0A1E84462190A57F0042F782 /* SyncAddDeviceViewController.swift in Sources */, + CA8D5C2426D7D07E009BF13D /* PlaylistThumbnailUtility.swift in Sources */, 4422D42C21BFCF8900BF1855 /* HttpsEverywhere.cpp in Sources */, 4422D50021BFFB7600BF1855 /* db_iter.cc in Sources */, 27B68E9425C8911D002D0826 /* BraveNewsAddSourceViewController.swift in Sources */, @@ -7523,7 +7607,6 @@ 7B844E3D1BBDDB9D00E733A2 /* ChevronView.swift in Sources */, 44331DD222551AFE007E3E93 /* ToolbarButton.swift in Sources */, CAC2CB402644488600BB8D36 /* RecentSearchHeaderView.swift in Sources */, - 5E99D02B25E56C18003F30B4 /* HLSThumbnailGenerator.swift in Sources */, 0A39FE942486604D00290ABC /* GRDServerManager.m in Sources */, 4422D4BC21BFFB7600BF1855 /* testharness.cc in Sources */, 3BE7275D1CCFE8B60099189F /* CustomSearchHandler.swift in Sources */, @@ -7550,6 +7633,7 @@ C8F457AA1F1FDD9B000CB895 /* BrowserViewController+KeyCommands.swift in Sources */, E40FAB0C1A7ABB77009CB80D /* WebServer.swift in Sources */, 4422D54C21BFFB7E00BF1855 /* strutil.cc in Sources */, + CA8D5C1C26D7CF04009BF13D /* PlaylistCarplayManager.swift in Sources */, 59A68D66379CFA85C4EAF00B /* TwoLineCell.swift in Sources */, A1AD4BE320C0861D007A6EA1 /* UIBarButtonItemExtensions.swift in Sources */, C6D267522136800100465DFA /* PrivateBrowsingManager.swift in Sources */, diff --git a/Client/Application/Delegates/AppDelegate.swift b/Client/Application/Delegates/AppDelegate.swift index 73f7b365587..a44b3c74d95 100644 --- a/Client/Application/Delegates/AppDelegate.swift +++ b/Client/Application/Delegates/AppDelegate.swift @@ -31,7 +31,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UIViewControllerRestorati var window: UIWindow? var browserViewController: BrowserViewController! var rootViewController: UIViewController! - var playlistRestorationController: UIViewController? // When Picture-In-Picture is enabled, we need to store a reference to the controller to keep it alive, otherwise if it deallocates, the system automatically kills Picture-In-Picture. weak var profile: Profile? var tabManager: TabManager! var braveCore: BraveCoreMain? @@ -51,6 +50,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UIViewControllerRestorati /// Must be added at launch according to Apple's documentation. let iapObserver = IAPObserver() + + /// A car-play manager instance that will handle when car-play status changes + var carPlayManager: PlaylistCarplayManager? @discardableResult func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Hold references to willFinishLaunching parameters for delayed app launch @@ -404,6 +406,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UIViewControllerRestorati AdblockResourceDownloader.shared.startLoading() PlaylistManager.shared.restoreSession() + carPlayManager = PlaylistCarplayManager.shared.then { + $0.browserController = browserViewController + } return shouldPerformAdditionalDelegateHandling } diff --git a/Client/Application/Shortcuts/ActivityShortcutManager.swift b/Client/Application/Shortcuts/ActivityShortcutManager.swift index 28f72568c3a..adb8cc80725 100644 --- a/Client/Application/Shortcuts/ActivityShortcutManager.swift +++ b/Client/Application/Shortcuts/ActivityShortcutManager.swift @@ -162,10 +162,11 @@ class ActivityShortcutManager: NSObject { bvc.present(container, animated: true) } case .openPlayList: - let playlistController = (UIApplication.shared.delegate as? AppDelegate)?.playlistRestorationController ?? PlaylistViewController(initialItem: nil, initialItemPlaybackOffset: 0.0) - playlistController.modalPresentationStyle = .fullScreen - - bvc.present(playlistController, animated: true) + let tab = bvc.tabManager.selectedTab + PlaylistCarplayManager.shared.getPlaylistController(tab: tab) { playlistController in + playlistController.modalPresentationStyle = .fullScreen + bvc.present(playlistController, animated: true) + } } } diff --git a/Client/Entitlements/Beta.entitlements b/Client/Entitlements/Beta.entitlements index bc2ed8db5cd..64663ec41de 100644 --- a/Client/Entitlements/Beta.entitlements +++ b/Client/Entitlements/Beta.entitlements @@ -22,5 +22,7 @@ group.$(MOZ_BUNDLE_ID) + com.apple.developer.playable-content + diff --git a/Client/Entitlements/Debug.entitlements b/Client/Entitlements/Debug.entitlements index a07be7bd65b..bfef0defa92 100644 --- a/Client/Entitlements/Debug.entitlements +++ b/Client/Entitlements/Debug.entitlements @@ -22,5 +22,7 @@ group.$(LOCAL_BUNDLE_ID) + com.apple.developer.playable-content + diff --git a/Client/Entitlements/Dev.entitlements b/Client/Entitlements/Dev.entitlements index c289202be1f..5c18e77ec9e 100644 --- a/Client/Entitlements/Dev.entitlements +++ b/Client/Entitlements/Dev.entitlements @@ -22,5 +22,7 @@ group.$(MOZ_BUNDLE_ID).unique + com.apple.developer.playable-content + diff --git a/Client/Entitlements/Enterprise.entitlements b/Client/Entitlements/Enterprise.entitlements index 39d9f128bb5..e67b89e8fa6 100644 --- a/Client/Entitlements/Enterprise.entitlements +++ b/Client/Entitlements/Enterprise.entitlements @@ -18,5 +18,7 @@ group.$(MOZ_BUNDLE_ID) + com.apple.developer.playable-content + diff --git a/Client/Entitlements/Release (AppStore).entitlements b/Client/Entitlements/Release (AppStore).entitlements index e534a9e06b4..bc11c40e57b 100644 --- a/Client/Entitlements/Release (AppStore).entitlements +++ b/Client/Entitlements/Release (AppStore).entitlements @@ -24,5 +24,7 @@ group.$(MOZ_BUNDLE_ID) + com.apple.developer.playable-content + diff --git a/Client/Entitlements/Release.entitlements b/Client/Entitlements/Release.entitlements index bc2ed8db5cd..64663ec41de 100644 --- a/Client/Entitlements/Release.entitlements +++ b/Client/Entitlements/Release.entitlements @@ -22,5 +22,7 @@ group.$(MOZ_BUNDLE_ID) + com.apple.developer.playable-content + diff --git a/Client/Frontend/Browser/BrowserViewController/BrowserViewController+Menu.swift b/Client/Frontend/Browser/BrowserViewController/BrowserViewController+Menu.swift index a2297cb2d07..9cca456e818 100644 --- a/Client/Frontend/Browser/BrowserViewController/BrowserViewController+Menu.swift +++ b/Client/Frontend/Browser/BrowserViewController/BrowserViewController+Menu.swift @@ -47,32 +47,18 @@ extension BrowserViewController { } MenuItemButton(icon: #imageLiteral(resourceName: "playlist_menu").template, title: Strings.playlistMenuItem) { [weak self] in guard let self = self else { return } - - let playlistController = (UIApplication.shared.delegate as? AppDelegate)?.playlistRestorationController - + // Present existing playlist controller - if let playlistController = playlistController { + if let playlistController = PlaylistCarplayManager.shared.playlistController { self.dismiss(animated: true) { self.present(playlistController, animated: true) } } else { // Retrieve the item and offset-time from the current tab's webview. - if let item = self.tabManager.selectedTab?.playlistItem, - let webView = self.tabManager.selectedTab?.webView { + let tab = self.tabManager.selectedTab + PlaylistCarplayManager.shared.getPlaylistController(tab: tab) { [weak self] playlistController in + guard let self = self else { return } - PlaylistHelper.getCurrentTime(webView: webView, nodeTag: item.tagId) { [weak self] currentTime in - guard let self = self else { return } - - let playlistController = PlaylistViewController(initialItem: item, initialItemPlaybackOffset: currentTime) - playlistController.modalPresentationStyle = .fullScreen - - self.dismiss(animated: true) { - self.present(playlistController, animated: true) - } - } - } else { - // Otherwise no item to play, so just open the playlist controller. - let playlistController = PlaylistViewController(initialItem: nil, initialItemPlaybackOffset: 0.0) playlistController.modalPresentationStyle = .fullScreen self.dismiss(animated: true) { diff --git a/Client/Frontend/Browser/Playlist/BrowserViewController+Playlist.swift b/Client/Frontend/Browser/Playlist/BrowserViewController+Playlist.swift index 0e281d0260e..e5eab8ba891 100644 --- a/Client/Frontend/Browser/Playlist/BrowserViewController+Playlist.swift +++ b/Client/Frontend/Browser/Playlist/BrowserViewController+Playlist.swift @@ -231,7 +231,7 @@ extension BrowserViewController: PlaylistHelperDelegate { } func openPlaylist(item: PlaylistInfo?, playbackOffset: Double) { - let playlistController = (UIApplication.shared.delegate as? AppDelegate)?.playlistRestorationController as? PlaylistViewController ?? PlaylistViewController(initialItem: item, initialItemPlaybackOffset: playbackOffset) + let playlistController = PlaylistCarplayManager.shared.getPlaylistController(initialItem: item, initialItemPlaybackOffset: playbackOffset) playlistController.modalPresentationStyle = .fullScreen /// Donate Open Playlist Activity for suggestions diff --git a/Client/Frontend/Browser/Playlist/Cache/HLSThumbnailGenerator.swift b/Client/Frontend/Browser/Playlist/Cache/HLSThumbnailGenerator.swift deleted file mode 100644 index e50f10e1717..00000000000 --- a/Client/Frontend/Browser/Playlist/Cache/HLSThumbnailGenerator.swift +++ /dev/null @@ -1,104 +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 -import AVFoundation -import CoreImage -import SDWebImage - -public class HLSThumbnailGenerator { - private enum State { - case loading - case ready - case failed - } - - private let asset: AVAsset - private let sourceURL: URL - private let player: AVPlayer? - private let videoOutput: AVPlayerItemVideoOutput? - private var observer: NSKeyValueObservation? - private var state: State = .loading - private let queue = DispatchQueue(label: "com.brave.hls-thumbnail-generator") - private let completion: (UIImage?, Error?) -> Void - - init(url: URL, time: TimeInterval, completion: @escaping (UIImage?, Error?) -> Void) { - self.asset = AVAsset(url: url) - self.sourceURL = url - self.completion = completion - - let item = AVPlayerItem(asset: asset, automaticallyLoadedAssetKeys: []) - self.player = AVPlayer(playerItem: item).then { - $0.rate = 0 - } - - self.videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: [ - kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA - ]) - - self.observer = self.player?.currentItem?.observe(\.status) { [weak self] item, _ in - guard let self = self else { return } - - if item.status == .readyToPlay && self.state == .loading { - self.state = .ready - self.generateThumbnail(at: time) - } else if item.status == .failed { - self.state = .failed - DispatchQueue.main.async { - self.completion(nil, "Failed to load item") - } - } - } - - if let videoOutput = self.videoOutput { - self.player?.currentItem?.add(videoOutput) - } - } - - private func generateThumbnail(at time: TimeInterval) { - queue.async { - let time = CMTime(seconds: time, preferredTimescale: 1) - self.player?.seek(to: time) { [weak self] finished in - guard let self = self else { return } - - if finished { - self.queue.async { - if let buffer = self.videoOutput?.copyPixelBuffer(forItemTime: time, itemTimeForDisplay: nil) { - self.snapshotPixelBuffer(buffer, atTime: time.seconds) - } else { - DispatchQueue.main.async { - self.completion(nil, "Cannot copy pixel-buffer (PBO)") - } - } - } - } else { - DispatchQueue.main.async { - self.completion(nil, "Failed to seek to specified time") - } - } - } - } - } - - private func snapshotPixelBuffer(_ buffer: CVPixelBuffer, atTime time: TimeInterval) { - let ciImage = CIImage(cvPixelBuffer: buffer) - let quartzFrame = CGRect(x: 0, y: 0, - width: CVPixelBufferGetWidth(buffer), - height: CVPixelBufferGetHeight(buffer)) - - if let cgImage = CIContext().createCGImage(ciImage, from: quartzFrame) { - let result = UIImage(cgImage: cgImage) - - DispatchQueue.main.async { - self.completion(result, nil) - } - } else { - DispatchQueue.main.async { - self.completion(nil, "Failed to create image from pixel-buffer frame.") - } - } - } - -} diff --git a/Client/Frontend/Browser/Playlist/Cells/PlaylistCell.swift b/Client/Frontend/Browser/Playlist/Cells/PlaylistCell.swift index 5d5dbb6f378..8bddf34fc22 100644 --- a/Client/Frontend/Browser/Playlist/Cells/PlaylistCell.swift +++ b/Client/Frontend/Browser/Playlist/Cells/PlaylistCell.swift @@ -40,8 +40,7 @@ class PlaylistResizingThumbnailView: UIImageView { } class PlaylistCell: UITableViewCell { - var thumbnailGenerator: HLSThumbnailGenerator? - var imageAssetGenerator: AVAssetImageGenerator? + let thumbnailGenerator = PlaylistThumbnailRenderer() var durationFetcher: PlaylistAssetFetcher? private let thumbnailMaskView = CAShapeLayer().then { @@ -97,8 +96,7 @@ class PlaylistCell: UITableViewCell { } func prepareForDisplay() { - thumbnailGenerator = nil - imageAssetGenerator = nil + thumbnailGenerator.cancel() thumbnailView.cancelFaviconLoad() durationFetcher?.cancelLoading() durationFetcher = nil diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift new file mode 100644 index 00000000000..12b832ed4f3 --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift @@ -0,0 +1,598 @@ +// 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 Combine +import CarPlay +import MediaPlayer +import Data +import BraveShared +import Shared + +private let log = Logger.browserLogger + +class PlaylistCarplayController: NSObject { + private let player: MediaPlayer + private let mediaStreamer: PlaylistMediaStreamer + private let contentManager: MPPlayableContentManager + private var playerStateObservers = Set() + private var assetStateObservers = Set() + private var assetLoadingStateObservers = Set() + private var playlistObservers = Set() + private var playlistItemIds = [String]() + private weak var browser: BrowserViewController? + + init(browser: BrowserViewController?, player: MediaPlayer, contentManager: MPPlayableContentManager) { + self.browser = browser + self.player = player + self.contentManager = contentManager + self.mediaStreamer = PlaylistMediaStreamer(playerView: browser?.view ?? UIView()) + super.init() + + observePlayerStates() + observePlaylistStates() + PlaylistManager.shared.reloadData() + + playlistItemIds = (0.. Void) { + + if indexPath.count == 2 { + // Item Section + DispatchQueue.main.async { + guard let mediaItem = PlaylistManager.shared.itemAtIndex(indexPath.item) else { + completionHandler(nil) + return + } + + self.contentManager.nowPlayingIdentifiers = [mediaItem.src] + self.playItem(item: mediaItem) { [weak self] error in + PlaylistCarplayManager.shared.currentPlaylistItem = nil + + switch error { + case .other(let error): + log.error(error) + completionHandler("Unknown Error") + case .expired: + completionHandler(Strings.PlayList.expiredAlertDescription) + case .none: + guard let self = self else { + completionHandler("Unknown Error") + return + } + + PlaylistCarplayManager.shared.currentlyPlayingItemIndex = indexPath.item + PlaylistCarplayManager.shared.currentPlaylistItem = mediaItem + PlaylistMediaStreamer.setNowPlayingMediaArtwork(artwork: self.contentItem(at: indexPath)?.artwork) + + completionHandler(nil) + case .cancelled: + log.debug("User Cancelled Playlist playback") + completionHandler(nil) + } + + // Workaround to see carplay NowPlaying on the simulator + #if targetEnvironment(simulator) + DispatchQueue.main.async { + UIApplication.shared.endReceivingRemoteControlEvents() + UIApplication.shared.beginReceivingRemoteControlEvents() + } + #endif + } + } + } else { + // Tab Section + completionHandler(nil) + } + } + + func beginLoadingChildItems(at indexPath: IndexPath, completionHandler: @escaping (Error?) -> Void) { + // For some odd reason, this is never called in the simulator. + // It is only called in the car and that's fine. + completionHandler(nil) + } +} + +extension PlaylistCarplayController: MPPlayableContentDataSource { + func numberOfChildItems(at indexPath: IndexPath) -> Int { + if indexPath.indices.count == 0 { + return 1 // 1 Tab. + } + + return playlistItemIds.count + } + + func childItemsDisplayPlaybackProgress(at indexPath: IndexPath) -> Bool { + true + } + + func contentItem(at indexPath: IndexPath) -> MPContentItem? { + // Tab Section + if indexPath.count == 1 { + let item = MPContentItem(identifier: "BravePlaylist") + item.title = "Brave Playlist" + item.isContainer = true + item.isPlayable = false + let imageIcon = #imageLiteral(resourceName: "settings-shields") + item.artwork = MPMediaItemArtwork(boundsSize: imageIcon.size, requestHandler: { _ -> UIImage in + return imageIcon + }) + return item + } + + if indexPath.count == 2 { + // Items section + guard let itemId = playlistItemIds[safe: indexPath.row] else { + return nil + } + + let item = MPContentItem(identifier: itemId) + + DispatchQueue.main.async { + guard let mediaItem = PlaylistManager.shared.itemAtIndex(indexPath.item) else { + return + } + + let cacheState = PlaylistManager.shared.state(for: mediaItem.src) + item.title = mediaItem.name + item.subtitle = mediaItem.pageSrc + item.isPlayable = true + item.isStreamingContent = cacheState != .downloaded + item.loadThumbnail(for: mediaItem) + } + return item + } + + return nil + } +} + +extension PlaylistCarplayController { + func onPreviousTrack(isUserInitiated: Bool) { + if PlaylistCarplayManager.shared.currentlyPlayingItemIndex <= 0 { + return + } + + let index = PlaylistCarplayManager.shared.currentlyPlayingItemIndex - 1 + if index < PlaylistManager.shared.numberOfAssets, + let item = PlaylistManager.shared.itemAtIndex(index) { + PlaylistCarplayManager.shared.currentlyPlayingItemIndex = index + playItem(item: item) { [weak self] error in + guard let self = self else { return } + + switch error { + case .other(let err): + log.error(err) + self.displayLoadingResourceError() + case .expired: + self.displayExpiredResourceError(item: item) + case .none: + PlaylistCarplayManager.shared.currentlyPlayingItemIndex = index + self.updateLastPlayedItem(item: item) + case .cancelled: + log.debug("User Cancelled Playlist Playback") + } + } + } + } + + func onNextTrack(isUserInitiated: Bool) { + let assetCount = PlaylistManager.shared.numberOfAssets + let isAtEnd = PlaylistCarplayManager.shared.currentlyPlayingItemIndex >= assetCount - 1 + var index = PlaylistCarplayManager.shared.currentlyPlayingItemIndex + + switch player.repeatState { + case .none: + if isAtEnd { + player.pictureInPictureController?.delegate = nil + player.pictureInPictureController?.stopPictureInPicture() + player.stop() + + PlaylistCarplayManager.shared.playlistController = nil + return + } + index += 1 + case .repeatOne: + player.seek(to: 0.0) + player.play() + return + case .repeatAll: + index = isAtEnd ? 0 : index + 1 + } + + if index >= 0, + let item = PlaylistManager.shared.itemAtIndex(index) { + self.playItem(item: item) { [weak self] error in + guard let self = self else { return } + + switch error { + case .other(let err): + log.error(err) + self.displayLoadingResourceError() + case .expired: + if isUserInitiated || self.player.repeatState == .repeatOne || assetCount <= 1 { + self.displayExpiredResourceError(item: item) + } else { + DispatchQueue.main.async { + PlaylistCarplayManager.shared.currentlyPlayingItemIndex = index + self.onNextTrack(isUserInitiated: isUserInitiated) + } + } + case .none: + PlaylistCarplayManager.shared.currentlyPlayingItemIndex = index + self.updateLastPlayedItem(item: item) + case .cancelled: + log.debug("User Cancelled Playlist Playback") + } + } + } + } + + private func play() { + player.play() + } + + private func pause() { + player.pause() + } + + private func stop() { + player.stop() + } + + private func seekBackwards() { + player.seekBackwards() + } + + private func seekForwards() { + player.seekForwards() + } + + private func seek(to time: TimeInterval) { + player.seek(to: time) + } + + func seek(relativeOffset: Float) { + if let currentItem = player.currentItem { + let seekTime = CMTimeMakeWithSeconds(Float64(CGFloat(relativeOffset) * CGFloat(currentItem.asset.duration.value) / CGFloat(currentItem.asset.duration.timescale)), preferredTimescale: currentItem.currentTime().timescale) + seek(to: seekTime.seconds) + } + } + + func load(url: URL, autoPlayEnabled: Bool) -> AnyPublisher { + load(asset: AVURLAsset(url: url), autoPlayEnabled: autoPlayEnabled) + } + + func load(asset: AVURLAsset, autoPlayEnabled: Bool) -> AnyPublisher { + assetLoadingStateObservers.removeAll() + player.stop() + + return Future { [weak self] resolver in + guard let self = self else { + resolver(.failure("User Cancelled Playback")) + return + } + + self.player.load(asset: asset) + .receive(on: RunLoop.main) + .sink(receiveCompletion: { status in + switch status { + case .failure(let error): + resolver(.failure(error)) + case .finished: + break + } + }, receiveValue: { [weak self] isNewItem in + guard let self = self else { + resolver(.failure("User Cancelled Playback")) + return + } + + guard self.player.currentItem != nil else { + resolver(.failure("Couldn't load playlist item")) + return + } + + // We are playing the same item again.. + if !isNewItem { + self.pause() + self.seek(relativeOffset: 0.0) // Restart playback + self.play() + resolver(.success(Void())) + return + } + + // Track-bar + if autoPlayEnabled { + resolver(.success(Void())) + self.play() // Play the new item + } + }).store(in: &self.assetLoadingStateObservers) + }.eraseToAnyPublisher() + } + + func playItem(item: PlaylistInfo, completion: ((PlaylistMediaStreamer.PlaybackError) -> Void)?) { + assetLoadingStateObservers.removeAll() + assetStateObservers.removeAll() + + // If the item is cached, load it from the cache and play it. + let cacheState = PlaylistManager.shared.state(for: item.pageSrc) + if cacheState != .invalid { + if let index = PlaylistManager.shared.index(of: item.pageSrc), + let asset = PlaylistManager.shared.assetAtIndex(index) { + load(asset: asset, autoPlayEnabled: true) + .handleEvents(receiveCancel: { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + }) + .sink(receiveCompletion: { status in + switch status { + case .failure(let error): + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.other(error)) + case .finished: + break + } + }, receiveValue: { [weak self] _ in + guard let self = self else { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + return + } + + PlaylistMediaStreamer.setNowPlayingInfo(item, withPlayer: self.player) + completion?(.none) + }).store(in: &assetLoadingStateObservers) + } else { + completion?(.expired) + } + return + } + + // The item is not cached so we should attempt to stream it + streamItem(item: item, completion: completion) + } + + func streamItem(item: PlaylistInfo, completion: ((PlaylistMediaStreamer.PlaybackError) -> Void)?) { + assetStateObservers.removeAll() + + mediaStreamer.loadMediaStreamingAsset(item) + .handleEvents(receiveCancel: { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + }) + .sink(receiveCompletion: { status in + switch status { + case .failure(let error): + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(error) + case .finished: + break + } + }, receiveValue: { [weak self] _ in + guard let self = self else { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + return + } + + // Item can be streamed, so let's retrieve its URL from our DB + guard let index = PlaylistManager.shared.index(of: item.pageSrc), + let item = PlaylistManager.shared.itemAtIndex(index) else { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.expired) + return + } + + // Attempt to play the stream + if let url = URL(string: item.pageSrc) { + self.load(url: url, autoPlayEnabled: true) + .handleEvents(receiveCancel: { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + }) + .sink(receiveCompletion: { status in + switch status { + case .failure(let error): + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.other(error)) + case .finished: + break + } + }, receiveValue: { [weak self] _ in + guard let self = self else { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + return + } + + PlaylistMediaStreamer.setNowPlayingInfo(item, withPlayer: self.player) + completion?(.none) + }).store(in: &self.assetLoadingStateObservers) + log.debug("Playing Live Video: \(self.player.isLiveMedia)") + } else { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.expired) + } + }).store(in: &assetStateObservers) + } + + func updateLastPlayedItem(item: PlaylistInfo) { + Preferences.Playlist.lastPlayedItemUrl.value = item.pageSrc + + if let playTime = player.currentItem?.currentTime(), + Preferences.Playlist.playbackLeftOff.value { + Preferences.Playlist.lastPlayedItemTime.value = playTime.seconds + } else { + Preferences.Playlist.lastPlayedItemTime.value = 0.0 + } + } + + func displayExpiredResourceError(item: PlaylistInfo?) { + if let item = item { + let alert = UIAlertController(title: Strings.PlayList.expiredAlertTitle, + message: Strings.PlayList.expiredAlertDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.PlayList.reopenButtonTitle, style: .default, handler: { [weak self] _ in + guard let browser = self?.browser else { return } + + if let url = URL(string: item.pageSrc) { + browser.dismiss(animated: true, completion: nil) + browser.openURLInNewTab(url, isPrivileged: false) + } + })) + alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel, handler: nil)) + browser?.present(alert, animated: true, completion: nil) + } else { + let alert = UIAlertController(title: Strings.PlayList.expiredAlertTitle, + message: Strings.PlayList.expiredAlertDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.OKString, style: .default, handler: nil)) + browser?.present(alert, animated: true, completion: nil) + } + } + + func displayLoadingResourceError() { + let alert = UIAlertController( + title: Strings.PlayList.sorryAlertTitle, message: Strings.PlayList.loadResourcesErrorAlertDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.PlayList.okayButtonTitle, style: .default, handler: nil)) + browser?.present(alert, animated: true, completion: nil) + } +} + +extension MPContentItem { + + func loadThumbnail(for mediaItem: PlaylistInfo) { + if thumbnailRenderer != nil { + return + } + + guard let assetUrl = URL(string: mediaItem.src), + let favIconUrl = URL(string: mediaItem.pageSrc) else { + return + } + + thumbnailRenderer = PlaylistThumbnailRenderer() + thumbnailRenderer?.loadThumbnail(assetUrl: assetUrl, + favIconUrl: favIconUrl, + completion: { [weak self] image in + guard let self = self else { return } + + let image = image ?? #imageLiteral(resourceName: "settings-shields") + self.artwork = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ -> UIImage in + return image + }) + }) + } + + private struct AssociatedKeys { + static var thumbnailRenderer: Int = 0 + } + + private var thumbnailRenderer: PlaylistThumbnailRenderer? { + get { objc_getAssociatedObject(self, &AssociatedKeys.thumbnailRenderer) as? PlaylistThumbnailRenderer } + set { objc_setAssociatedObject(self, &AssociatedKeys.thumbnailRenderer, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } +} diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistDetailViewController.swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistDetailViewController.swift new file mode 100644 index 00000000000..8dcb1f12188 --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistDetailViewController.swift @@ -0,0 +1,163 @@ +// 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 BraveShared +import Shared +import Data + +class PlaylistDetailViewController: UIViewController, UIGestureRecognizerDelegate { + + private var playerView: VideoView? + + override func viewDidLoad() { + super.viewDidLoad() + + setup() + layoutBarButtons() + addGestureRecognizers() + } + + // MARK: Private + + private func setup() { + view.backgroundColor = .black + + navigationController?.do { + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.titleTextAttributes = [.foregroundColor: UIColor.white] + appearance.backgroundColor = .braveBackground + + $0.navigationBar.standardAppearance = appearance + $0.navigationBar.barTintColor = UIColor.braveBackground + $0.navigationBar.tintColor = .white + } + } + + private func layoutBarButtons() { + let exitBarButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(onExit(_:))) + let sideListBarButton = UIBarButtonItem(image: #imageLiteral(resourceName: "playlist_split_navigation"), style: .done, target: self, action: #selector(onDisplayModeChange)) + + navigationItem.rightBarButtonItem = + PlayListSide(rawValue: Preferences.Playlist.listViewSide.value) == .left ? exitBarButton : sideListBarButton + navigationItem.leftBarButtonItem = + PlayListSide(rawValue: Preferences.Playlist.listViewSide.value) == .left ? sideListBarButton : exitBarButton + } + + private func addGestureRecognizers() { + let slideToRevealGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleGesture)) + slideToRevealGesture.direction = PlayListSide(rawValue: Preferences.Playlist.listViewSide.value) == .left ? .right : .left + + view.addGestureRecognizer(slideToRevealGesture) + } + + private func updateSplitViewDisplayMode(to displayMode: UISplitViewController.DisplayMode) { + UIView.animate(withDuration: 0.2) { + self.splitViewController?.preferredDisplayMode = displayMode + } + } + + // MARK: Actions + + func onSidePanelStateChanged() { + onDisplayModeChange() + } + + func onFullscreen() { + navigationController?.setNavigationBarHidden(true, animated: true) + + if navigationController?.isNavigationBarHidden == true { + splitViewController?.preferredDisplayMode = .secondaryOnly + } + } + + func onExitFullscreen() { + navigationController?.setNavigationBarHidden(false, animated: true) + + if navigationController?.isNavigationBarHidden == true { + splitViewController?.preferredDisplayMode = .primaryOverlay + } + } + + @objc + private func onExit(_ button: UIBarButtonItem) { + dismiss(animated: true, completion: nil) + } + + @objc + func handleGesture(gesture: UISwipeGestureRecognizer) { + guard gesture.direction == .right, + let playerView = playerView, + !playerView.controlsView.trackBar.frame.contains(gesture.location(in: view)) else { + return + } + + onDisplayModeChange() + } + + @objc + private func onDisplayModeChange() { + updateSplitViewDisplayMode( + to: splitViewController?.displayMode == .primaryOverlay ? .secondaryOnly : .primaryOverlay) + } + + public func setVideoPlayer(_ videoPlayer: VideoView?) { + if playerView?.superview == view { + playerView?.removeFromSuperview() + } + + playerView = videoPlayer + } + + public func updateLayoutForMode(_ mode: UIUserInterfaceIdiom) { + guard let playerView = playerView else { return } + + if mode == .pad { + view.addSubview(playerView) + playerView.snp.makeConstraints { + $0.bottom.leading.trailing.equalTo(view) + $0.top.equalTo(view.safeAreaLayoutGuide) + } + } else { + if playerView.superview == view { + playerView.removeFromSuperview() + } + } + } +} + +// MARK: - Error Handling + +extension PlaylistDetailViewController { + func displayExpiredResourceError(item: PlaylistInfo?) { + if let item = item { + let alert = UIAlertController(title: Strings.PlayList.expiredAlertTitle, + message: Strings.PlayList.expiredAlertDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.PlayList.reopenButtonTitle, style: .default, handler: { _ in + + if let url = URL(string: item.pageSrc) { + self.dismiss(animated: true, completion: nil) + (UIApplication.shared.delegate as? AppDelegate)?.browserViewController.openURLInNewTab(url, isPrivileged: false) + } + })) + alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel, handler: nil)) + self.present(alert, animated: true, completion: nil) + } else { + let alert = UIAlertController(title: Strings.PlayList.expiredAlertTitle, + message: Strings.PlayList.expiredAlertDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.OKString, style: .default, handler: nil)) + self.present(alert, animated: true, completion: nil) + } + } + + func displayLoadingResourceError() { + let alert = UIAlertController( + title: Strings.PlayList.sorryAlertTitle, message: Strings.PlayList.loadResourcesErrorAlertDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.PlayList.okayButtonTitle, style: .default, handler: nil)) + + self.present(alert, animated: true, completion: nil) + } +} diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+DragDropDelegate.swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+DragDropDelegate.swift new file mode 100644 index 00000000000..bbff7f0745a --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+DragDropDelegate.swift @@ -0,0 +1,100 @@ +// 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 UIKit + +// MARK: - Reordering of cells + +extension PlaylistListViewController: UITableViewDragDelegate, UITableViewDropDelegate { + func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { + true + } + + func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + .none + } + + func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { + false + } + + func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { + PlaylistManager.shared.reorderItems(from: sourceIndexPath, to: destinationIndexPath) { + PlaylistManager.shared.reloadData() + } + } + + func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + let item = PlaylistManager.shared.itemAtIndex(indexPath.row) + let dragItem = UIDragItem(itemProvider: NSItemProvider()) + dragItem.localObject = item + return [dragItem] + } + + func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal { + + var dropProposal = UITableViewDropProposal(operation: .cancel) + guard session.items.count == 1 else { return dropProposal } + + if tableView.hasActiveDrag { + dropProposal = UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) + } + return dropProposal + } + + func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) { + guard let sourceIndexPath = coordinator.items.first?.sourceIndexPath else { return } + let destinationIndexPath: IndexPath + if let indexPath = coordinator.destinationIndexPath { + destinationIndexPath = indexPath + } else { + let section = tableView.numberOfSections - 1 + let row = tableView.numberOfRows(inSection: section) + destinationIndexPath = IndexPath(row: row, section: section) + } + + if coordinator.proposal.operation == .move { + guard let item = coordinator.items.first else { return } + _ = coordinator.drop(item.dragItem, toRowAt: destinationIndexPath) + tableView.moveRow(at: sourceIndexPath, to: destinationIndexPath) + } + } + + func tableView(_ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? { + guard let cell = tableView.cellForRow(at: indexPath) as? PlaylistCell else { return nil } + + let preview = UIDragPreviewParameters() + preview.visiblePath = UIBezierPath(roundedRect: cell.contentView.frame, cornerRadius: 12.0) + preview.backgroundColor = slightlyLighterColour(color: UIColor.braveBackground) + return preview + } + + func tableView(_ tableView: UITableView, dropPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? { + guard let cell = tableView.cellForRow(at: indexPath) as? PlaylistCell else { return nil } + + let preview = UIDragPreviewParameters() + preview.visiblePath = UIBezierPath(roundedRect: cell.contentView.frame, cornerRadius: 12.0) + preview.backgroundColor = slightlyLighterColour(color: UIColor.braveBackground) + return preview + } + + func tableView(_ tableView: UITableView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool { + true + } + + private func slightlyLighterColour(color: UIColor) -> UIColor { + let desaturation: CGFloat = 0.5 + var h: CGFloat = 0, s: CGFloat = 0 + var b: CGFloat = 0, a: CGFloat = 0 + + guard color.getHue(&h, saturation: &s, brightness: &b, alpha: &a) else {return color} + + return UIColor(hue: h, + saturation: max(s - desaturation, 0.0), + brightness: b, + alpha: a) + } +} diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift new file mode 100644 index 00000000000..cf5f52341bd --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDataSource.swift @@ -0,0 +1,286 @@ +// 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 UIKit +import AVKit +import AVFoundation +import Data +import Shared + +private let log = Logger.browserLogger + +// MARK: UITableViewDataSource + +extension PlaylistListViewController: UITableViewDataSource { + private static let formatter = DateComponentsFormatter().then { + $0.allowedUnits = [.day, .hour, .minute, .second] + $0.unitsStyle = .abbreviated + $0.maximumUnitCount = 2 + } + + private func getRelativeDateFormat(date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + formatter.dateTimeStyle = .numeric + return formatter.localizedString(fromTimeInterval: date.timeIntervalSinceNow) + } + + private func getAssetDuration(item: PlaylistInfo, _ completion: @escaping (TimeInterval?, AVAsset?) -> Void) -> PlaylistAssetFetcher? { + let tolerance: Double = 0.00001 + let distance = abs(item.duration.distance(to: 0.0)) + + // If the database duration is live/indefinite + if item.duration.isInfinite || + abs(item.duration.distance(to: TimeInterval.greatestFiniteMagnitude)) < tolerance { + completion(TimeInterval.infinity, nil) + return nil + } + + // If the database duration is 0.0 + if distance >= tolerance { + // Return the database duration + completion(item.duration, nil) + return nil + } + + guard let index = PlaylistManager.shared.index(of: item.pageSrc) else { + completion(item.duration, nil) // Return the database duration + return nil + } + + // Attempt to retrieve the duration from the Asset file + guard let asset = PlaylistManager.shared.assetAtIndex(index) else { + completion(item.duration, nil) // Return the database duration + return nil + } + + // Accessing tracks blocks the main-thread if not already loaded + // So we first need to check the track status before attempting to access it! + var error: NSError? + let trackStatus = asset.statusOfValue(forKey: "tracks", error: &error) + if let error = error { + log.error("AVAsset.statusOfValue error occurred: \(error)") + } + + if trackStatus == .loaded { + if !asset.tracks.isEmpty, + let track = asset.tracks(withMediaType: .video).first ?? + asset.tracks(withMediaType: .audio).first { + if track.timeRange.duration.isIndefinite { + completion(TimeInterval.infinity, nil) + } else { + completion(track.timeRange.duration.seconds, asset) + } + return nil + } + } else { + log.debug("AVAsset.statusOfValue not loaded. Status: \(trackStatus)") + } + + // Accessing duration or commonMetadata blocks the main-thread if not already loaded + // So we first need to check the track status before attempting to access it! + let durationStatus = asset.statusOfValue(forKey: "duration", error: &error) + if let error = error { + log.error("AVAsset.statusOfValue error occurred: \(error)") + } + + if durationStatus == .loaded { + // If it's live/indefinite + if asset.duration.isIndefinite { + completion(TimeInterval.infinity, asset) + return nil + } + + // If it's a valid duration + if abs(asset.duration.seconds.distance(to: 0.0)) >= tolerance { + completion(asset.duration.seconds, asset) + return nil + } + } else { + log.debug("AVAsset.statusOfValue not loaded. Status: \(durationStatus)") + } + + switch Reach().connectionStatus() { + case .offline, .unknown: + completion(item.duration, nil) // Return the database duration + return nil + case .online: + break + } + + // We can't get the duration synchronously so we need to let the AVAsset load the media item + // and hopefully we get a valid duration from that. + asset.loadValuesAsynchronously(forKeys: ["playable", "tracks", "duration"]) { + var error: NSError? + let trackStatus = asset.statusOfValue(forKey: "tracks", error: &error) + if let error = error { + log.error("AVAsset.statusOfValue error occurred: \(error)") + } + + let durationStatus = asset.statusOfValue(forKey: "tracks", error: &error) + if let error = error { + log.error("AVAsset.statusOfValue error occurred: \(error)") + } + + if trackStatus == .cancelled || durationStatus == .cancelled { + return + } + + if trackStatus == .failed && durationStatus == .failed, let error = error { + if error.code == NSURLErrorNoPermissionsToReadFile { + // Media item is expired.. permission is denied + log.debug("Playlist Media Item Expired: \(item.pageSrc)") + + ensureMainThread { + completion(nil, nil) + } + } else { + log.error("An unknown error occurred while attempting to fetch track and duration information: \(error)") + + ensureMainThread { + completion(nil, nil) + } + } + + return + } + + var duration: CMTime = .zero + if trackStatus == .loaded { + if let track = asset.tracks(withMediaType: .video).first ?? asset.tracks(withMediaType: .audio).first { + duration = track.timeRange.duration + } else { + duration = asset.duration + } + } else if durationStatus == .loaded { + duration = asset.duration + } + + ensureMainThread { + if duration.isIndefinite { + completion(TimeInterval.infinity, asset) + } else if abs(duration.seconds.distance(to: 0.0)) > tolerance { + let newItem = PlaylistInfo(name: item.name, + src: item.src, + pageSrc: item.pageSrc, + pageTitle: item.pageTitle, + mimeType: item.mimeType, + duration: duration.seconds, + detected: item.detected, + dateAdded: item.dateAdded, + tagId: item.tagId) + + PlaylistItem.updateItem(newItem) { + completion(duration.seconds, asset) + } + } else { + completion(duration.seconds, asset) + } + } + } + + return PlaylistAssetFetcher(asset: asset) + } + + func getAssetDurationFormatted(item: PlaylistInfo, _ completion: @escaping (String) -> Void) -> PlaylistAssetFetcher? { + return getAssetDuration(item: item) { duration, asset in + let domain = URL(string: item.pageSrc)?.baseDomain ?? "0s" + if let duration = duration { + if duration.isInfinite { + // Live video/audio + completion(Strings.PlayList.playlistLiveMediaStream) + } else if abs(duration.distance(to: 0.0)) > 0.00001 { + completion(Self.formatter.string(from: duration) ?? domain) + } else { + completion(domain) + } + } else { + // Media Item is expired or some sort of error occurred retrieving its duration + // Whatever the reason, we mark it as expired now + completion(Strings.PlayList.expiredLabelTitle) + } + } + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + PlaylistManager.shared.numberOfAssets + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + Constants.tableRowHeight + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + Constants.tableHeaderHeight + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: Constants.playListCellIdentifier, for: indexPath) as? PlaylistCell else { + return UITableViewCell() + } + + return cell + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let cell = cell as? PlaylistCell, + let item = PlaylistManager.shared.itemAtIndex(indexPath.row) else { + return + } + + cell.prepareForDisplay() + let domain = URL(string: item.pageSrc)?.baseDomain ?? "0s" + + cell.do { + $0.selectionStyle = .none + $0.titleLabel.text = item.name + $0.detailLabel.text = domain + $0.contentView.backgroundColor = .clear + $0.backgroundColor = .clear + $0.thumbnailView.image = nil + $0.thumbnailView.backgroundColor = .black + } + + let cacheState = PlaylistManager.shared.state(for: item.pageSrc) + switch cacheState { + case .inProgress: + cell.durationFetcher = getAssetDurationFormatted(item: item) { + cell.detailLabel.text = "\($0) - \(Strings.PlayList.savingForOfflineLabelTitle)" + } + case .downloaded: + if let itemSize = PlaylistManager.shared.sizeOfDownloadedItem(for: item.pageSrc) { + cell.durationFetcher = getAssetDurationFormatted(item: item) { + cell.detailLabel.text = "\($0) - \(itemSize)" + } + } else { + cell.durationFetcher = getAssetDurationFormatted(item: item) { + cell.detailLabel.text = "\($0) - \(Strings.PlayList.savedForOfflineLabelTitle)" + } + } + case .invalid: + cell.durationFetcher = getAssetDurationFormatted(item: item) { + cell.detailLabel.text = $0 + } + } + + // Load the HLS/Media thumbnail. If it fails, fall-back to favIcon + if let assetUrl = URL(string: item.src), let favIconUrl = URL(string: item.pageSrc) { + cell.thumbnailActivityIndicator.startAnimating() + cell.thumbnailGenerator.loadThumbnail(assetUrl: assetUrl, favIconUrl: favIconUrl) { [weak cell] image in + guard let cell = cell else { return } + + cell.thumbnailView.image = image ?? FaviconFetcher.defaultFaviconImage + cell.thumbnailView.backgroundColor = .black + cell.thumbnailView.contentMode = .scaleAspectFit + cell.thumbnailActivityIndicator.stopAnimating() + } + } + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return UIView() + } +} diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDelegate.swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDelegate.swift new file mode 100644 index 00000000000..53d57ad2adb --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController+TableViewDelegate.swift @@ -0,0 +1,139 @@ +// 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 UIKit +import BraveShared +import Shared +import Data +import MediaPlayer + +private let log = Logger.browserLogger + +// MARK: UITableViewDelegate + +extension PlaylistListViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + + if indexPath.row < 0 || indexPath.row >= PlaylistManager.shared.numberOfAssets { + return nil + } + + guard let currentItem = PlaylistManager.shared.itemAtIndex(indexPath.row) else { + return nil + } + + let cacheState = PlaylistManager.shared.state(for: currentItem.pageSrc) + + let cacheAction = UIContextualAction(style: .normal, title: nil, handler: { [weak self] (action, view, completionHandler) in + guard let self = self else { return } + + switch cacheState { + case .inProgress: + PlaylistManager.shared.cancelDownload(item: currentItem) + tableView.reloadRows(at: [indexPath], with: .automatic) + case .invalid: + if PlaylistManager.shared.isDiskSpaceEncumbered() { + let style: UIAlertController.Style = UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet + let alert = UIAlertController( + title: Strings.PlayList.playlistDiskSpaceWarningTitle, message: Strings.PlayList.playlistDiskSpaceWarningMessage, preferredStyle: style) + + alert.addAction(UIAlertAction(title: Strings.OKString, style: .default, handler: { _ in + PlaylistManager.shared.download(item: currentItem) + tableView.reloadRows(at: [indexPath], with: .automatic) + })) + + alert.addAction(UIAlertAction(title: Strings.CancelString, style: .cancel, handler: nil)) + self.present(alert, animated: true, completion: nil) + } else { + PlaylistManager.shared.download(item: currentItem) + tableView.reloadRows(at: [indexPath], with: .automatic) + } + case .downloaded: + let style: UIAlertController.Style = UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet + let alert = UIAlertController( + title: Strings.PlayList.removePlaylistOfflineDataAlertTitle, message: Strings.PlayList.removePlaylistOfflineDataAlertMessage, preferredStyle: style) + + alert.addAction(UIAlertAction(title: Strings.PlayList.removeActionButtonTitle, style: .destructive, handler: { _ in + _ = PlaylistManager.shared.deleteCache(item: currentItem) + tableView.reloadRows(at: [indexPath], with: .automatic) + })) + + alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel, handler: nil)) + self.present(alert, animated: true, completion: nil) + } + + completionHandler(true) + }) + + let deleteAction = UIContextualAction(style: .normal, title: nil, handler: { [weak self] (action, view, completionHandler) in + guard let self = self else { return } + + let style: UIAlertController.Style = UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet + let alert = UIAlertController( + title: Strings.PlayList.removePlaylistVideoAlertTitle, message: Strings.PlayList.removePlaylistVideoAlertMessage, preferredStyle: style) + + alert.addAction(UIAlertAction(title: Strings.PlayList.removeActionButtonTitle, style: .destructive, handler: { [weak self] _ in + guard let self = self else { return } + + self.delegate?.deleteItem(item: currentItem, at: indexPath.row) + + if self.delegate?.currentPlaylistItem == nil { + self.updateTableBackgroundView() + self.activityIndicator.stopAnimating() + } + })) + + alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel, handler: nil)) + self.present(alert, animated: true, completion: nil) + + completionHandler(true) + }) + + cacheAction.image = cacheState == .invalid ? #imageLiteral(resourceName: "playlist_download") : #imageLiteral(resourceName: "playlist_delete_download") + cacheAction.backgroundColor = #colorLiteral(red: 0.4509803922, green: 0.4784313725, blue: 0.8705882353, alpha: 1) + + deleteAction.image = #imageLiteral(resourceName: "playlist_delete_item") + deleteAction.backgroundColor = #colorLiteral(red: 0.9176470588, green: 0.2274509804, blue: 0.05098039216, alpha: 1) + + return UISwipeActionsConfiguration(actions: [deleteAction, cacheAction]) + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if tableView.isEditing { + tableView.setEditing(false, animated: true) + return + } + + prepareToPlayItem(at: indexPath) { [weak self] item in + guard let item = item else { + self?.activityIndicator.stopAnimating() + return + } + + self?.delegate?.playItem(item: item) { [weak self] error in + guard let self = self else { return } + self.activityIndicator.stopAnimating() + + switch error { + case .other(let err): + log.error(err) + self.commitPlayerItemTransaction(at: indexPath, isExpired: false) + self.delegate?.displayLoadingResourceError() + case .expired: + self.commitPlayerItemTransaction(at: indexPath, isExpired: true) + self.delegate?.displayExpiredResourceError(item: item) + case .none: + self.commitPlayerItemTransaction(at: indexPath, isExpired: false) + self.delegate?.updateLastPlayedItem(item: item) + case .cancelled: + self.commitPlayerItemTransaction(at: indexPath, isExpired: false) + log.debug("User cancelled Playlist Playback") + } + } + } + } +} diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController..swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController..swift new file mode 100644 index 00000000000..21fbdd713af --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController..swift @@ -0,0 +1,622 @@ +// 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 MediaPlayer +import AVFoundation +import AVKit +import CoreData +import Combine + +// Third-Party +import SDWebImage + +import BraveShared +import Shared +import Data + +private let log = Logger.browserLogger + +// MARK: - PlaylistListViewController + +class PlaylistListViewController: UIViewController { + // MARK: Constants + + struct Constants { + static let playListCellIdentifier = "playlistCellIdentifier" + static let tableRowHeight: CGFloat = 80 + static let tableHeaderHeight: CGFloat = 11 + } + + // MARK: Properties + public var initialItem: PlaylistInfo? + public var initialItemPlaybackOffset = 0.0 + + weak var delegate: PlaylistViewControllerDelegate? + private let playerView: VideoView + private let contentManager = MPPlayableContentManager.shared() + private var observers = Set() + private(set) var autoPlayEnabled = Preferences.Playlist.firstLoadAutoPlay.value + var playerController: AVPlayerViewController? + + let activityIndicator = UIActivityIndicatorView(style: .medium).then { + $0.isHidden = true + $0.hidesWhenStopped = true + } + + let tableView = UITableView(frame: .zero, style: .grouped).then { + $0.backgroundView = UIView() + $0.backgroundColor = .braveBackground + $0.separatorColor = .clear + $0.allowsSelectionDuringEditing = true + } + + init(playerView: VideoView) { + self.playerView = playerView + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + PlaylistManager.shared.contentWillChange + .receive(on: RunLoop.main) + .sink { [weak self] in + self?.controllerWillChangeContent() + }.store(in: &observers) + + PlaylistManager.shared.contentDidChange + .receive(on: RunLoop.main) + .sink { [weak self] in + self?.controllerDidChangeContent() + }.store(in: &observers) + + PlaylistManager.shared.objectDidChange + .receive(on: RunLoop.main) + .sink { [weak self] in + self?.controllerDidChange($0.object, + at: $0.indexPath, + for: $0.type, + newIndexPath: $0.newIndexPath) + }.store(in: &observers) + + PlaylistManager.shared.downloadProgressUpdated + .receive(on: RunLoop.main) + .sink { [weak self] in + self?.onDownloadProgressUpdate(id: $0.id, + percentComplete: $0.percentComplete) + }.store(in: &observers) + + PlaylistManager.shared.downloadStateChanged + .receive(on: RunLoop.main) + .sink { [weak self] in + self?.onDownloadStateChanged(id: $0.id, + state: $0.state, + displayName: $0.displayName, + error: $0.error) + }.store(in: &observers) + + // Theme + title = Strings.PlayList.playListSectionTitle + view.backgroundColor = .braveBackground + navigationController?.do { + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.titleTextAttributes = [.foregroundColor: UIColor.white] + appearance.backgroundColor = .braveBackground + + $0.navigationBar.standardAppearance = appearance + $0.navigationBar.barTintColor = UIColor.braveBackground + $0.navigationBar.tintColor = .white + } + + // Layout + tableView.do { + $0.register(PlaylistCell.self, forCellReuseIdentifier: Constants.playListCellIdentifier) + $0.dataSource = self + $0.delegate = self + $0.dragDelegate = self + $0.dropDelegate = self + $0.dragInteractionEnabled = true + } + + // Update + DispatchQueue.main.async { + self.fetchResults() + } + } + + // MARK: Internal + + private func fetchResults() { + updateTableBackgroundView() + playerView.setControlsEnabled(false) + + let initialItem = self.initialItem + let initialItemOffset = self.initialItemPlaybackOffset + self.initialItem = nil + self.initialItemPlaybackOffset = 0.0 + + PlaylistManager.shared.reloadData() + tableView.reloadData() + contentManager.reloadData() + + // After reloading all data, update the background + guard PlaylistManager.shared.numberOfAssets > 0 else { + updateTableBackgroundView() + autoPlayEnabled = true + return + } + + // Otherwise prepare to play the first item + updateTableBackgroundView() + playerView.setControlsEnabled(true) + + // If car play is active or media is already playing, do nothing + if PlaylistCarplayManager.shared.isCarPlayAvailable && (delegate?.currentPlaylistAsset != nil || delegate?.isPlaying ?? false) { + autoPlayEnabled = true + return + } + + // Setup initial playback item and time-offset + let lastPlayedItemUrl = initialItem?.pageSrc ?? Preferences.Playlist.lastPlayedItemUrl.value + let lastPlayedItemTime = initialItem != nil ? initialItemOffset : Preferences.Playlist.lastPlayedItemTime.value + autoPlayEnabled = initialItem != nil ? true : Preferences.Playlist.firstLoadAutoPlay.value + + // If there is no last played item, then just select the first item in the playlist + // which will play it if auto-play is enabled. + guard let lastPlayedItemUrl = lastPlayedItemUrl, + let index = PlaylistManager.shared.index(of: lastPlayedItemUrl) else { + + tableView.delegate?.tableView?(tableView, didSelectRowAt: IndexPath(row: 0, section: 0)) + autoPlayEnabled = true + return + } + + // Prepare the UI before playing the item + let indexPath = IndexPath(row: index, section: 0) + prepareToPlayItem(at: indexPath) { [weak self] item in + guard let self = self, + let delegate = self.delegate, + let item = item else { + self?.commitPlayerItemTransaction(at: indexPath, + isExpired: false) + return + } + + delegate.playItem(item: item) { [weak self] error in + PlaylistCarplayManager.shared.currentPlaylistItem = nil + + guard let self = self, + let delegate = self.delegate else { + self?.commitPlayerItemTransaction(at: indexPath, + isExpired: false) + return + } + + switch error { + case .cancelled: + self.commitPlayerItemTransaction(at: indexPath, + isExpired: false) + case .other(let err): + log.error(err) + self.commitPlayerItemTransaction(at: indexPath, + isExpired: false) + delegate.displayLoadingResourceError() + case .expired: + self.commitPlayerItemTransaction(at: indexPath, + isExpired: true) + delegate.displayExpiredResourceError(item: item) + case .none: + PlaylistCarplayManager.shared.currentPlaylistItem = item + self.commitPlayerItemTransaction(at: indexPath, + isExpired: false) + + // Update the player position/time-offset of the last played item + self.seekLastPlayedItem(at: indexPath, + lastPlayedItemUrl: lastPlayedItemUrl, + lastPlayedTime: lastPlayedItemTime) + + // Even if the item was NOT previously the last played item, + // it is now as it has begun to play + delegate.updateLastPlayedItem(item: item) + } + } + } + + autoPlayEnabled = true + } + + private func seekLastPlayedItem(at indexPath: IndexPath, lastPlayedItemUrl: String, lastPlayedTime: Double) { + // The item can be deleted at any time, + // so we need to guard against it and make sure the index path matches up correctly + // If it does, we check the last played time + // and seek to that position in the media item + let item = PlaylistManager.shared.itemAtIndex(indexPath.row) + guard let item = item else { return } + + if item.pageSrc == lastPlayedItemUrl && + lastPlayedTime > 0.0 && + lastPlayedTime < delegate?.currentPlaylistAsset?.duration.seconds ?? 0.0 && + Preferences.Playlist.playbackLeftOff.value { + self.playerView.seek(to: Preferences.Playlist.lastPlayedItemTime.value) + } + } + + // MARK: Actions + + @objc + private func onExit(_ button: UIBarButtonItem) { + dismiss(animated: true, completion: nil) + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + .lightContent + } + + public func updateLayoutForMode(_ mode: UIUserInterfaceIdiom) { + navigationItem.rightBarButtonItem = nil + + if mode == .phone { + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(onExit(_:))) + + playerView.setSidePanelHidden(true) + + // If the player view is in fullscreen, we should NOT change the tableView layout on rotation. + view.addSubview(tableView) + view.addSubview(playerView) + playerView.addSubview(activityIndicator) + + if !playerView.isFullscreen { + if UIDevice.current.orientation.isLandscape && UIDevice.isPhone { + playerView.setExitButtonHidden(false) + playerView.setFullscreenButtonHidden(true) + playerView.snp.remakeConstraints { + $0.edges.equalTo(view.snp.edges) + } + + activityIndicator.snp.remakeConstraints { + $0.center.equalToSuperview() + } + } else { + playerView.setFullscreenButtonHidden(false) + playerView.setExitButtonHidden(true) + let videoPlayerHeight = (1.0 / 3.0) * (UIScreen.main.bounds.width > UIScreen.main.bounds.height ? UIScreen.main.bounds.width : UIScreen.main.bounds.height) + + tableView.do { + $0.contentInset = UIEdgeInsets(top: videoPlayerHeight, left: 0.0, bottom: view.safeAreaInsets.bottom, right: 0.0) + $0.scrollIndicatorInsets = $0.contentInset + $0.contentOffset = CGPoint(x: 0.0, y: -videoPlayerHeight) + $0.isHidden = false + } + + playerView.snp.remakeConstraints { + $0.top.equalTo(view.safeArea.top) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(videoPlayerHeight) + } + + activityIndicator.snp.remakeConstraints { + $0.center.equalToSuperview() + } + + tableView.snp.remakeConstraints { + $0.edges.equalToSuperview() + } + + // On iPhone-8, 14.4, I need to scroll the tableView after setting its contentOffset and contentInset + // Otherwise the layout is broken when exiting fullscreen in portrait mode. + if PlaylistManager.shared.numberOfAssets > 0 { + tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true) + } + } + } else { + playerView.snp.remakeConstraints { + $0.edges.equalToSuperview() + } + + activityIndicator.snp.remakeConstraints { + $0.center.equalToSuperview() + } + } + } else { + if splitViewController?.isCollapsed == true { + playerView.setFullscreenButtonHidden(false) + playerView.setExitButtonHidden(true) + playerView.setSidePanelHidden(true) + } else { + playerView.setFullscreenButtonHidden(true) + playerView.setExitButtonHidden(false) + playerView.setSidePanelHidden(false) + } + + view.addSubview(tableView) + playerView.addSubview(activityIndicator) + + tableView.do { + $0.contentInset = .zero + $0.scrollIndicatorInsets = $0.contentInset + $0.contentOffset = .zero + } + + activityIndicator.snp.remakeConstraints { + $0.center.equalToSuperview() + } + + tableView.snp.remakeConstraints { + $0.edges.equalToSuperview() + } + } + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + if UIDevice.isPhone && splitViewController?.isCollapsed == true { + updateLayoutForMode(.phone) + + if !playerView.isFullscreen { + navigationController?.setNavigationBarHidden(UIDevice.current.orientation.isLandscape, animated: true) + } + } + } +} + +extension PlaylistListViewController { + func updateTableBackgroundView() { + if PlaylistManager.shared.numberOfAssets > 0 { + tableView.backgroundView = nil + tableView.separatorStyle = .singleLine + } else { + let messageLabel = UILabel(frame: view.bounds).then { + $0.text = Strings.PlayList.noItemLabelTitle + $0.textColor = .white + $0.numberOfLines = 0 + $0.textAlignment = .center + $0.font = .systemFont(ofSize: 18.0, weight: .medium) + $0.sizeToFit() + } + + tableView.backgroundView = messageLabel + tableView.separatorStyle = .none + } + } + + func prepareToPlayItem(at indexPath: IndexPath, _ completion: ((PlaylistInfo?) -> Void)?) { + // Update the UI in preparation to play an item + // Show the activity indicator, update the cell and player view, etc. + guard indexPath.row < PlaylistManager.shared.numberOfAssets, + let item = PlaylistManager.shared.itemAtIndex(indexPath.row) else { + completion?(nil) + return + } + + activityIndicator.startAnimating() + activityIndicator.isHidden = false + + let selectedCell = tableView.cellForRow(at: indexPath) as? PlaylistCell + playerView.setVideoInfo(videoDomain: item.pageSrc, videoTitle: item.pageTitle) + PlaylistMediaStreamer.setNowPlayingMediaArtwork(image: selectedCell?.thumbnailView.image) + completion?(item) + } + + func commitPlayerItemTransaction(at indexPath: IndexPath, isExpired: Bool) { + if isExpired { + let selectedCell = tableView.cellForRow(at: indexPath) as? PlaylistCell + selectedCell?.detailLabel.text = Strings.PlayList.expiredLabelTitle + } + + activityIndicator.stopAnimating() + } +} + +// MARK: - Error Handling + +extension PlaylistListViewController { + func displayExpiredResourceError(item: PlaylistInfo?) { + if let item = item { + let alert = UIAlertController(title: Strings.PlayList.expiredAlertTitle, + message: Strings.PlayList.expiredAlertDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.PlayList.reopenButtonTitle, style: .default, handler: { _ in + + if let url = URL(string: item.pageSrc) { + self.dismiss(animated: true, completion: nil) + (UIApplication.shared.delegate as? AppDelegate)?.browserViewController.openURLInNewTab(url, isPrivileged: false) + } + })) + alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel, handler: nil)) + self.present(alert, animated: true, completion: nil) + } else { + let alert = UIAlertController(title: Strings.PlayList.expiredAlertTitle, + message: Strings.PlayList.expiredAlertDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.OKString, style: .default, handler: nil)) + self.present(alert, animated: true, completion: nil) + } + } + + func displayLoadingResourceError() { + let alert = UIAlertController( + title: Strings.PlayList.sorryAlertTitle, message: Strings.PlayList.loadResourcesErrorAlertDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.PlayList.okayButtonTitle, style: .default, handler: nil)) + + self.present(alert, animated: true, completion: nil) + } +} + +extension PlaylistListViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + return !tableView.isEditing + } +} + +// MARK: VideoViewDelegate + +extension PlaylistListViewController { + + func onFullscreen() { + navigationController?.setNavigationBarHidden(true, animated: true) + tableView.isHidden = true + playerView.snp.remakeConstraints { + $0.edges.equalToSuperview() + } + } + + func onExitFullscreen() { + if UIDevice.isIpad && splitViewController?.isCollapsed == false { + playerView.setFullscreenButtonHidden(true) + playerView.setExitButtonHidden(false) + splitViewController?.parent?.dismiss(animated: true, completion: nil) + } else if UIDevice.isIpad && splitViewController?.isCollapsed == true { + navigationController?.setNavigationBarHidden(false, animated: true) + playerView.setFullscreenButtonHidden(true) + updateLayoutForMode(.phone) + } else if UIDevice.current.orientation.isPortrait { + navigationController?.setNavigationBarHidden(false, animated: true) + tableView.isHidden = false + updateLayoutForMode(.phone) + } else { + playerView.setFullscreenButtonHidden(true) + playerView.setExitButtonHidden(false) + splitViewController?.parent?.dismiss(animated: true, completion: nil) + } + } +} + +// MARK: - PlaylistManagerDelegate + +extension PlaylistListViewController: PlaylistManagerDelegate { + func onDownloadProgressUpdate(id: String, percentComplete: Double) { + guard let index = PlaylistManager.shared.index(of: id) else { + return + } + + let indexPath = IndexPath(row: index, section: 0) + guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? PlaylistCell else { + return + } + + // Cell is not visible, do not update percentages + if tableView.indexPathsForVisibleRows?.contains(indexPath) == false { + return + } + + guard let item = PlaylistManager.shared.itemAtIndex(index) else { + return + } + + switch PlaylistManager.shared.state(for: id) { + case .inProgress: + cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in + cell?.detailLabel.text = "\($0) - \(Int(percentComplete))% \(Strings.PlayList.savedForOfflineLabelTitle)" + } + case .downloaded: + if let itemSize = PlaylistManager.shared.sizeOfDownloadedItem(for: item.pageSrc) { + cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in + cell?.detailLabel.text = "\($0) - \(itemSize)" + } + } else { + cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in + cell?.detailLabel.text = "\($0) - \(Strings.PlayList.savedForOfflineLabelTitle)" + } + } + case .invalid: + cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in + cell?.detailLabel.text = $0 + } + } + } + + func onDownloadStateChanged(id: String, state: PlaylistDownloadManager.DownloadState, displayName: String?, error: Error?) { + guard let index = PlaylistManager.shared.index(of: id) else { + return + } + + let indexPath = IndexPath(row: index, section: 0) + guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? PlaylistCell else { + return + } + + // Cell is not visible, do not update status + if tableView.indexPathsForVisibleRows?.contains(indexPath) == false { + return + } + + guard let item = PlaylistManager.shared.itemAtIndex(index) else { + return + } + + if let error = error { + log.error("Error downloading playlist item: \(error)") + + cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in + cell?.detailLabel.text = $0 + } + + let alert = UIAlertController(title: Strings.PlayList.playlistSaveForOfflineErrorTitle, + message: Strings.PlayList.playlistSaveForOfflineErrorMessage, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.PlayList.okayButtonTitle, style: .default, handler: nil)) + self.present(alert, animated: true, completion: nil) + } else { + switch state { + case .inProgress: + cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in + cell?.detailLabel.text = "\($0) - \(Strings.PlayList.savingForOfflineLabelTitle)" + } + case .downloaded: + if let itemSize = PlaylistManager.shared.sizeOfDownloadedItem(for: item.pageSrc) { + cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in + cell?.detailLabel.text = "\($0) - \(itemSize)" + } + } else { + cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in + cell?.detailLabel.text = "\($0) - \(Strings.PlayList.savedForOfflineLabelTitle)" + } + } + case .invalid: + cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in + cell?.detailLabel.text = $0 + } + } + } + } + + func controllerDidChange(_ anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { + + if tableView.hasActiveDrag || tableView.hasActiveDrop { return } + + switch type { + case .insert: + guard let newIndexPath = newIndexPath else { break } + tableView.insertRows(at: [newIndexPath], with: .fade) + case .delete: + guard let indexPath = indexPath else { break } + tableView.deleteRows(at: [indexPath], with: .fade) + case .update: + guard let indexPath = indexPath else { break } + tableView.reloadRows(at: [indexPath], with: .fade) + case .move: + guard let indexPath = indexPath, + let newIndexPath = newIndexPath else { break } + tableView.deleteRows(at: [indexPath], with: .fade) + tableView.insertRows(at: [newIndexPath], with: .fade) + default: + break + } + } + + func controllerDidChangeContent() { + if tableView.hasActiveDrag || tableView.hasActiveDrop { return } + tableView.endUpdates() + } + + func controllerWillChangeContent() { + if tableView.hasActiveDrag || tableView.hasActiveDrop { return } + tableView.beginUpdates() + } +} diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistViewController+AVDelegates.swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistViewController+AVDelegates.swift new file mode 100644 index 00000000000..b6482e970bf --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistViewController+AVDelegates.swift @@ -0,0 +1,62 @@ +// 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 AVKit +import AVFoundation +import Shared + +// MARK: - AVPictureInPictureControllerDelegate + +extension PlaylistViewController: AVPictureInPictureControllerDelegate { + + func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + + PlaylistCarplayManager.shared.playlistController = splitViewController?.parent as? PlaylistViewController + + if UIDevice.isIpad { + splitViewController?.dismiss(animated: true, completion: nil) + } + } + + func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + if UIDevice.isPhone { + DispatchQueue.main.async { + self.dismiss(animated: true, completion: nil) + } + } + } + + func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + if UIDevice.isIpad { + attachPlayerView() + } + + PlaylistCarplayManager.shared.playlistController = nil + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) { + + let alert = UIAlertController(title: Strings.PlayList.sorryAlertTitle, + message: Strings.PlayList.pictureInPictureErrorTitle, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: Strings.PlayList.okayButtonTitle, style: .default, handler: nil)) + self.present(alert, animated: true, completion: nil) + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { + + if let restorationController = PlaylistCarplayManager.shared.playlistController { + restorationController.modalPresentationStyle = .fullScreen + if view.window == nil { + PlaylistCarplayManager.shared.browserController?.present(restorationController, + animated: true) + } + + PlaylistCarplayManager.shared.playlistController = nil + } + + completionHandler(true) + } +} diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift new file mode 100644 index 00000000000..3ba489abef5 --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistViewController.swift @@ -0,0 +1,834 @@ +// 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 +import UIKit +import AVKit +import AVFoundation +import CarPlay +import MediaPlayer +import Combine + +import BraveShared +import Shared +import SDWebImage +import CoreData +import Data + +private let log = Logger.browserLogger + +// MARK: PlaylistViewControllerDelegate +protocol PlaylistViewControllerDelegate: AnyObject { + func attachPlayerView() + func detachPlayerView() + func onSidePanelStateChanged() + func onFullscreen() + func onExitFullscreen() + func playItem(item: PlaylistInfo, completion: ((PlaylistMediaStreamer.PlaybackError) -> Void)?) + func deleteItem(item: PlaylistInfo, at index: Int) + func updateLastPlayedItem(item: PlaylistInfo) + func displayLoadingResourceError() + func displayExpiredResourceError(item: PlaylistInfo) + + var isPlaying: Bool { get } + var currentPlaylistItem: AVPlayerItem? { get } + var currentPlaylistAsset: AVAsset? { get } +} + +// MARK: PlaylistViewController + +class PlaylistViewController: UIViewController { + + // MARK: Properties + + private let player: MediaPlayer + private let playerView = VideoView() + private lazy var mediaStreamer = PlaylistMediaStreamer(playerView: playerView) + + private let splitController = UISplitViewController() + private lazy var listController = PlaylistListViewController(playerView: playerView) + private let detailController = PlaylistDetailViewController() + + private var playerStateObservers = Set() + private var assetStateObservers = Set() + private var assetLoadingStateObservers = Set() + + init(mediaPlayer: MediaPlayer, initialItem: PlaylistInfo?, initialItemPlaybackOffset: Double) { + self.player = mediaPlayer + super.init(nibName: nil, bundle: nil) + + listController.initialItem = initialItem + listController.initialItemPlaybackOffset = initialItemPlaybackOffset + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + // Store the last played item's time-offset + if let playTime = player.currentItem?.currentTime(), + Preferences.Playlist.playbackLeftOff.value { + Preferences.Playlist.lastPlayedItemTime.value = playTime.seconds + } else { + Preferences.Playlist.lastPlayedItemTime.value = 0.0 + } + + // Stop picture in picture + player.pictureInPictureController?.delegate = nil + player.pictureInPictureController?.stopPictureInPicture() + + // Stop media playback + if !PlaylistCarplayManager.shared.isCarPlayAvailable { + stop(playerView) + PlaylistCarplayManager.shared.currentPlaylistItem = nil + } + + // If this controller is retained in app-delegate for Picture-In-Picture support + // then we need to re-attach the player layer + // and deallocate it. + if UIDevice.isIpad { + playerView.attachLayer(player: player) + } + + PlaylistCarplayManager.shared.playlistController = nil + } + + override func viewDidLoad() { + super.viewDidLoad() + + overrideUserInterfaceStyle = .dark + + // Setup delegates and state observers + attachPlayerView() + updatePlayerUI() + observePlayerStates() + listController.delegate = self + + // Layout + splitController.do { + $0.viewControllers = [SettingsNavigationController(rootViewController: listController), + SettingsNavigationController(rootViewController: detailController)] + $0.delegate = self + $0.primaryEdge = PlayListSide(rawValue: Preferences.Playlist.listViewSide.value) == .left ? .leading : .trailing + $0.presentsWithGesture = false + $0.maximumPrimaryColumnWidth = 400 + $0.minimumPrimaryColumnWidth = 400 + } + + addChild(splitController) + view.addSubview(splitController.view) + + splitController.do { + $0.didMove(toParent: self) + $0.view.translatesAutoresizingMaskIntoConstraints = false + $0.view.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + // Updates + updateLayoutForOrientationChange() + + detailController.setVideoPlayer(playerView) + detailController.navigationController?.setNavigationBarHidden(splitController.isCollapsed || traitCollection.horizontalSizeClass == .regular, animated: false) + + if UIDevice.isPhone { + if splitController.isCollapsed == false && traitCollection.horizontalSizeClass == .regular { + listController.updateLayoutForMode(.pad) + detailController.updateLayoutForMode(.pad) + } else { + listController.updateLayoutForMode(.phone) + detailController.updateLayoutForMode(.phone) + + // On iPhone Pro Max which displays like an iPad, we need to hide navigation bar. + if UIDevice.isPhone && UIDevice.current.orientation.isLandscape { + listController.onFullscreen() + } + } + } else { + listController.updateLayoutForMode(.pad) + detailController.updateLayoutForMode(.pad) + } + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + updateLayoutForOrientationChange() + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + private func updateLayoutForOrientationChange() { + if playerView.isFullscreen { + splitController.preferredDisplayMode = .secondaryOnly + } else { + if UIDevice.current.orientation.isLandscape { + splitController.preferredDisplayMode = .secondaryOnly + } else { + splitController.preferredDisplayMode = .primaryOverlay + } + } + } + + private func updatePlayerUI() { + // Update play/pause button + if isPlaying { + playerView.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_pause"), for: .normal) + } else { + playerView.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_play"), for: .normal) + } + + // Update play-backrate button + let playbackRate = player.rate + let button = playerView.controlsView.playbackRateButton + + if playbackRate <= 1.0 { + button.setTitle("1x", for: .normal) + } else if playbackRate == 1.5 { + button.setTitle("1.5x", for: .normal) + } else { + button.setTitle("2x", for: .normal) + } + + // Update repeatMode button + switch repeatMode { + case .none: + playerView.controlsView.repeatButton.setImage(#imageLiteral(resourceName: "playlist_repeat"), for: .normal) + case .repeatOne: + playerView.controlsView.repeatButton.setImage(#imageLiteral(resourceName: "playlist_repeat_one"), for: .normal) + case .repeatAll: + playerView.controlsView.repeatButton.setImage(#imageLiteral(resourceName: "playlist_repeat_all"), for: .normal) + } + + if let item = PlaylistCarplayManager.shared.currentPlaylistItem { + playerView.setVideoInfo(videoDomain: item.pageSrc, videoTitle: item.pageTitle) + } else { + playerView.resetVideoInfo() + } + } + + private func observePlayerStates() { + player.publisher(for: .play).sink { [weak self] _ in + self?.playerView.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_pause"), for: .normal) + }.store(in: &playerStateObservers) + + player.publisher(for: .pause).sink { [weak self] _ in + self?.playerView.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_play"), for: .normal) + }.store(in: &playerStateObservers) + + player.publisher(for: .stop).sink { [weak self] _ in + self?.playerView.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_play"), for: .normal) + }.store(in: &playerStateObservers) + + player.publisher(for: .changePlaybackRate).sink { [weak self] _ in + guard let self = self else { return } + + let playbackRate = self.player.rate + let button = self.playerView.controlsView.playbackRateButton + + if playbackRate <= 1.0 { + button.setTitle("1x", for: .normal) + } else if playbackRate == 1.5 { + button.setTitle("1.5x", for: .normal) + } else { + button.setTitle("2x", for: .normal) + } + }.store(in: &playerStateObservers) + + player.publisher(for: .changeRepeatMode).sink { [weak self] _ in + guard let self = self else { return } + switch self.repeatMode { + case .none: + self.playerView.controlsView.repeatButton.setImage(#imageLiteral(resourceName: "playlist_repeat"), for: .normal) + case .repeatOne: + self.playerView.controlsView.repeatButton.setImage(#imageLiteral(resourceName: "playlist_repeat_one"), for: .normal) + case .repeatAll: + self.playerView.controlsView.repeatButton.setImage(#imageLiteral(resourceName: "playlist_repeat_all"), for: .normal) + } + }.store(in: &playerStateObservers) + + player.publisher(for: .finishedPlaying).sink { [weak self] event in + guard let self = self, + let currentItem = event.mediaPlayer.currentItem else { return } + + self.playerView.controlsView.playPauseButton.isEnabled = false + self.playerView.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_pause"), for: .normal) + event.mediaPlayer.pause() + + let endTime = CMTimeConvertScale(currentItem.asset.duration, timescale: event.mediaPlayer.currentTime.timescale, method: .roundHalfAwayFromZero) + + self.playerView.controlsView.trackBar.setTimeRange(currentTime: currentItem.currentTime(), endTime: endTime) + event.mediaPlayer.seek(to: .zero) + + self.playerView.controlsView.playPauseButton.isEnabled = true + self.playerView.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_play"), for: .normal) + + self.playerView.toggleOverlays(showOverlay: true) + self.onNextTrack(self.playerView, isUserInitiated: false) + }.store(in: &playerStateObservers) + + player.publisher(for: .periodicPlayTimeChanged).sink { [weak self] event in + guard let self = self, let currentItem = event.mediaPlayer.currentItem else { return } + + let endTime = CMTimeConvertScale(currentItem.asset.duration, timescale: event.mediaPlayer.currentTime.timescale, method: .roundHalfAwayFromZero) + + if CMTimeCompare(endTime, .zero) != 0 && endTime.value > 0 { + self.playerView.controlsView.trackBar.setTimeRange(currentTime: event.mediaPlayer.currentTime, endTime: endTime) + } + }.store(in: &playerStateObservers) + + self.playerView.infoView.pictureInPictureButton.isEnabled = + AVPictureInPictureController.isPictureInPictureSupported() + player.publisher(for: .pictureInPictureStatusChanged).sink { [weak self] event in + guard let self = self else { return } + + self.playerView.infoView.pictureInPictureButton.isEnabled = + event.mediaPlayer.pictureInPictureController?.isPictureInPicturePossible == true + }.store(in: &playerStateObservers) + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate + +extension PlaylistViewController: UIAdaptivePresentationControllerDelegate { + func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return .fullScreen + } +} + +// MARK: - UISplitViewControllerDelegate + +extension PlaylistViewController: UISplitViewControllerDelegate { + func splitViewControllerSupportedInterfaceOrientations(_ splitViewController: UISplitViewController) -> UIInterfaceOrientationMask { + return .allButUpsideDown + } + + func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool { + + // On iPhone, always display the iPhone layout (collapsed) no matter what. + // On iPad, we need to update both the list controller's layout (collapsed) and the detail controller's layout (collapsed). + listController.updateLayoutForMode(.phone) + detailController.setVideoPlayer(nil) + detailController.updateLayoutForMode(.phone) + return true + } + + func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? { + + // On iPhone, always display the iPad layout (expanded) when not in compact mode. + // On iPad, we need to update both the list controller's layout (expanded) and the detail controller's layout (expanded). + listController.updateLayoutForMode(.pad) + detailController.setVideoPlayer(playerView) + detailController.updateLayoutForMode(.pad) + + if UIDevice.isPhone { + detailController.navigationController?.setNavigationBarHidden(true, animated: true) + } + + return detailController.navigationController ?? detailController + } +} + +// MARK: - PlaylistViewControllerDelegate + +extension PlaylistViewController: PlaylistViewControllerDelegate { + func attachPlayerView() { + playerView.delegate = self + playerView.attachLayer(player: player) + } + + func detachPlayerView() { + playerView.delegate = nil + playerView.detachLayer() + } + + func onSidePanelStateChanged() { + detailController.onSidePanelStateChanged() + } + + func onFullscreen() { + if !UIDevice.isIpad || splitViewController?.isCollapsed == true { + listController.onFullscreen() + } else { + detailController.onFullscreen() + } + } + + func onExitFullscreen() { + if !UIDevice.isIpad || splitViewController?.isCollapsed == true { + listController.onExitFullscreen() + } else { + detailController.onExitFullscreen() + } + } + + func deleteItem(item: PlaylistInfo, at index: Int) { + PlaylistManager.shared.delete(item: item) + + if PlaylistCarplayManager.shared.currentlyPlayingItemIndex == index { + PlaylistMediaStreamer.clearNowPlayingInfo() + + PlaylistCarplayManager.shared.currentlyPlayingItemIndex = -1 + playerView.resetVideoInfo() + stop(playerView) + + // Cancel all loading. + assetLoadingStateObservers.removeAll() + assetStateObservers.removeAll() + } + } + + func updateLastPlayedItem(item: PlaylistInfo) { + Preferences.Playlist.lastPlayedItemUrl.value = item.pageSrc + + if let playTime = player.currentItem?.currentTime(), + Preferences.Playlist.playbackLeftOff.value { + Preferences.Playlist.lastPlayedItemTime.value = playTime.seconds + } else { + Preferences.Playlist.lastPlayedItemTime.value = 0.0 + } + } + + func displayLoadingResourceError() { + let isPrimaryDisplayMode = splitController.displayMode == .primaryOverlay + if isPrimaryDisplayMode { + listController.displayLoadingResourceError() + } else { + detailController.displayLoadingResourceError() + } + } + + func displayExpiredResourceError(item: PlaylistInfo) { + let isPrimaryDisplayMode = splitController.displayMode == .primaryOverlay + if isPrimaryDisplayMode { + listController.displayExpiredResourceError(item: item) + } else { + detailController.displayExpiredResourceError(item: item) + } + } + + var currentPlaylistItem: AVPlayerItem? { + player.currentItem + } + + var currentPlaylistAsset: AVAsset? { + player.currentItem?.asset + } +} + +// MARK: - VideoViewDelegate + +extension PlaylistViewController: VideoViewDelegate { + func onSidePanelStateChanged(_ videoView: VideoView) { + onSidePanelStateChanged() + } + + func onPreviousTrack(_ videoView: VideoView, isUserInitiated: Bool) { + if PlaylistCarplayManager.shared.currentlyPlayingItemIndex <= 0 { + return + } + + let index = PlaylistCarplayManager.shared.currentlyPlayingItemIndex - 1 + if index < PlaylistManager.shared.numberOfAssets { + let indexPath = IndexPath(row: index, section: 0) + listController.prepareToPlayItem(at: indexPath) { [weak self] item in + guard let self = self, + let item = item else { + + self?.listController.commitPlayerItemTransaction(at: indexPath, isExpired: false) + return + } + + PlaylistCarplayManager.shared.currentlyPlayingItemIndex = indexPath.row + self.playItem(item: item) { [weak self] error in + guard let self = self else { return } + + switch error { + case .other(let err): + log.error(err) + self.listController.commitPlayerItemTransaction(at: indexPath, isExpired: false) + self.displayLoadingResourceError() + case .expired: + self.listController.commitPlayerItemTransaction(at: indexPath, isExpired: true) + self.displayExpiredResourceError(item: item) + case .none: + self.listController.commitPlayerItemTransaction(at: indexPath, isExpired: false) + PlaylistCarplayManager.shared.currentlyPlayingItemIndex = index + self.updateLastPlayedItem(item: item) + case .cancelled: + self.listController.commitPlayerItemTransaction(at: indexPath, isExpired: false) + log.debug("User Cancelled Playlist Playback") + } + } + } + } + } + + func onNextTrack(_ videoView: VideoView, isUserInitiated: Bool) { + let assetCount = PlaylistManager.shared.numberOfAssets + let isAtEnd = PlaylistCarplayManager.shared.currentlyPlayingItemIndex >= assetCount - 1 + var index = PlaylistCarplayManager.shared.currentlyPlayingItemIndex + + switch repeatMode { + case .none: + if isAtEnd { + player.pictureInPictureController?.delegate = nil + player.pictureInPictureController?.stopPictureInPicture() + player.stop() + + if UIDevice.isIpad { + playerView.attachLayer(player: player) + } + PlaylistCarplayManager.shared.playlistController = nil + return + } + index += 1 + case .repeatOne: + player.seek(to: 0.0) + player.play() + return + case .repeatAll: + index = isAtEnd ? 0 : index + 1 + } + + if index >= 0 { + let indexPath = IndexPath(row: index, section: 0) + listController.prepareToPlayItem(at: indexPath) { [weak self] item in + guard let self = self, + let item = item else { + + self?.listController.commitPlayerItemTransaction(at: indexPath, isExpired: false) + return + } + + self.playItem(item: item) { [weak self] error in + guard let self = self else { return } + + switch error { + case .other(let err): + log.error(err) + self.listController.commitPlayerItemTransaction(at: indexPath, isExpired: false) + self.displayLoadingResourceError() + case .expired: + if isUserInitiated || self.repeatMode == .repeatOne || assetCount <= 1 { + self.listController.commitPlayerItemTransaction(at: indexPath, isExpired: true) + self.displayExpiredResourceError(item: item) + } else { + DispatchQueue.main.async { + self.listController.commitPlayerItemTransaction(at: indexPath, isExpired: false) + PlaylistCarplayManager.shared.currentlyPlayingItemIndex = index + self.onNextTrack(videoView, isUserInitiated: isUserInitiated) + } + } + case .none: + self.listController.commitPlayerItemTransaction(at: indexPath, isExpired: false) + PlaylistCarplayManager.shared.currentlyPlayingItemIndex = index + self.updateLastPlayedItem(item: item) + case .cancelled: + self.listController.commitPlayerItemTransaction(at: indexPath, isExpired: false) + log.debug("User Cancelled Playlist Playback") + } + } + } + } + } + + func onPictureInPicture(_ videoView: VideoView) { + guard let pictureInPictureController = player.pictureInPictureController else { return } + + DispatchQueue.main.async { + if pictureInPictureController.isPictureInPictureActive { + // Picture in Picture disabled + pictureInPictureController.delegate = self + pictureInPictureController.stopPictureInPicture() + } else { + if #available(iOS 14.0, *) { + pictureInPictureController.requiresLinearPlayback = false + } + + // Picture in Picture enabled + pictureInPictureController.delegate = self + pictureInPictureController.startPictureInPicture() + } + } + } + + func onFullscreen(_ videoView: VideoView) { + onFullscreen() + } + + func onExitFullscreen(_ videoView: VideoView) { + onExitFullscreen() + } + + func play(_ videoView: VideoView) { + if isPlaying { + playerView.toggleOverlays(showOverlay: playerView.isOverlayDisplayed) + } else { + playerView.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_pause"), for: .normal) + playerView.toggleOverlays(showOverlay: false) + playerView.isOverlayDisplayed = false + + player.play() + } + } + + func pause(_ videoView: VideoView) { + if isPlaying { + playerView.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_play"), for: .normal) + playerView.toggleOverlays(showOverlay: true) + playerView.isOverlayDisplayed = true + + player.pause() + } else { + playerView.toggleOverlays(showOverlay: playerView.isOverlayDisplayed) + } + } + + func stop(_ videoView: VideoView) { + playerView.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_play"), for: .normal) + playerView.toggleOverlays(showOverlay: true) + playerView.isOverlayDisplayed = true + + player.stop() + } + + func seekBackwards(_ videoView: VideoView) { + player.seekBackwards() + } + + func seekForwards(_ videoView: VideoView) { + player.seekForwards() + } + + func seek(_ videoView: VideoView, to time: TimeInterval) { + player.seek(to: time) + } + + func seek(_ videoView: VideoView, relativeOffset: Float) { + if let currentItem = player.currentItem { + let seekTime = CMTimeMakeWithSeconds(Float64(CGFloat(relativeOffset) * CGFloat(currentItem.asset.duration.value) / CGFloat(currentItem.asset.duration.timescale)), preferredTimescale: currentItem.currentTime().timescale) + seek(videoView, to: seekTime.seconds) + } + } + + func setPlaybackRate(_ videoView: VideoView, rate: Float) { + player.setPlaybackRate(rate: rate) + } + + func togglePlayerGravity(_ videoView: VideoView) { + player.toggleGravity() + } + + func toggleRepeatMode(_ videoView: VideoView) { + player.toggleRepeatMode() + } + + func load(_ videoView: VideoView, url: URL, autoPlayEnabled: Bool) -> AnyPublisher { + load(videoView, asset: AVURLAsset(url: url), autoPlayEnabled: autoPlayEnabled) + } + + func load(_ videoView: VideoView, asset: AVURLAsset, autoPlayEnabled: Bool) -> AnyPublisher { + assetLoadingStateObservers.removeAll() + player.stop() + + return Future { [weak self] resolver in + guard let self = self else { + resolver(.failure("User Cancelled Playback")) + return + } + + self.player.load(asset: asset) + .receive(on: RunLoop.main) + .sink(receiveCompletion: { status in + switch status { + case .failure(let error): + resolver(.failure(error)) + case .finished: + break + } + }, receiveValue: { [weak self] isNewItem in + guard let self = self else { + resolver(.failure("User Cancelled Playback")) + return + } + + guard let item = self.player.currentItem else { + resolver(.failure("Couldn't load playlist item")) + return + } + + // We are playing the same item again.. + if !isNewItem { + self.pause(videoView) + self.seek(videoView, relativeOffset: 0.0) // Restart playback + self.play(videoView) + resolver(.success(Void())) + return + } + + // Live media item + let isPlayingLiveMedia = self.player.isLiveMedia + self.playerView.controlsView.trackBar.isUserInteractionEnabled = !isPlayingLiveMedia + self.playerView.controlsView.skipBackButton.isEnabled = !isPlayingLiveMedia + self.playerView.controlsView.skipForwardButton.isEnabled = !isPlayingLiveMedia + + // Track-bar + let endTime = CMTimeConvertScale(item.asset.duration, timescale: self.player.currentTime.timescale, method: .roundHalfAwayFromZero) + self.playerView.controlsView.trackBar.setTimeRange(currentTime: item.currentTime(), endTime: endTime) + + // Successfully loaded + resolver(.success(Void())) + + if autoPlayEnabled { + self.play(videoView) // Play the new item + } + }).store(in: &self.assetLoadingStateObservers) + }.eraseToAnyPublisher() + } + + func playItem(item: PlaylistInfo, completion: ((PlaylistMediaStreamer.PlaybackError) -> Void)?) { + assetLoadingStateObservers.removeAll() + assetStateObservers.removeAll() + + // This MUST be checked. + // The user must not be able to alter a player that isn't visible from any UI! + // This is because, if car-play is interface is attached, the player can only be + // controller through this UI so long as it is attached to it. + // If it isn't attached, the player can only be controlled through the car-play interface. + guard player.isAttachedToDisplay else { + completion?(.cancelled) + return + } + + // If the item is cached, load it from the cache and play it. + let cacheState = PlaylistManager.shared.state(for: item.pageSrc) + if cacheState != .invalid { + if let index = PlaylistManager.shared.index(of: item.pageSrc), + let asset = PlaylistManager.shared.assetAtIndex(index) { + load(playerView, asset: asset, autoPlayEnabled: listController.autoPlayEnabled) + .handleEvents(receiveCancel: { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + }) + .sink(receiveCompletion: { status in + switch status { + case .failure(let error): + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.other(error)) + case .finished: + break + } + }, receiveValue: { [weak self] _ in + guard let self = self else { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + return + } + + PlaylistMediaStreamer.setNowPlayingInfo(item, withPlayer: self.player) + completion?(.none) + }).store(in: &assetLoadingStateObservers) + } else { + completion?(.expired) + } + return + } + + // The item is not cached so we should attempt to stream it + streamItem(item: item, completion: completion) + } + + func streamItem(item: PlaylistInfo, completion: ((PlaylistMediaStreamer.PlaybackError) -> Void)?) { + mediaStreamer.loadMediaStreamingAsset(item) + .handleEvents(receiveCancel: { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + }) + .sink(receiveCompletion: { status in + switch status { + case .failure(let error): + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(error) + case .finished: + break + } + }, receiveValue: { [weak self] _ in + guard let self = self else { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + return + } + + // Item can be streamed, so let's retrieve its URL from our DB + guard let index = PlaylistManager.shared.index(of: item.pageSrc), + let item = PlaylistManager.shared.itemAtIndex(index) else { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.expired) + return + } + + // Attempt to play the stream + if let url = URL(string: item.pageSrc) { + self.load(self.playerView, + url: url, + autoPlayEnabled: self.listController.autoPlayEnabled) + .handleEvents(receiveCancel: { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + }) + .sink(receiveCompletion: { status in + switch status { + case .failure(let error): + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.other(error)) + case .finished: + break + } + }, receiveValue: { [weak self] _ in + guard let self = self else { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.cancelled) + return + } + + PlaylistMediaStreamer.setNowPlayingInfo(item, withPlayer: self.player) + completion?(.none) + }).store(in: &self.assetLoadingStateObservers) + log.debug("Playing Live Video: \(self.player.isLiveMedia)") + } else { + PlaylistMediaStreamer.clearNowPlayingInfo() + completion?(.expired) + } + }).store(in: &assetStateObservers) + } + + var isPlaying: Bool { + player.isPlaying + } + + var repeatMode: MediaPlayer.RepeatMode { + player.repeatState + } + + var playbackRate: Float { + return player.rate + } + + var isVideoTracksAvailable: Bool { + if let asset = player.currentItem?.asset { + return asset.isVideoTracksAvailable() + } + + // 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. + return true + } +} diff --git a/Client/Frontend/Browser/Playlist/Cache/PlaylistCacheLoader.swift b/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistCacheLoader.swift similarity index 100% rename from Client/Frontend/Browser/Playlist/Cache/PlaylistCacheLoader.swift rename to Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistCacheLoader.swift diff --git a/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift b/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift new file mode 100644 index 00000000000..32a7d5c45ce --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift @@ -0,0 +1,130 @@ +// 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 Combine +import MediaPlayer +import Shared +import Data + +private let log = Logger.browserLogger + +/// Lightweight class that manages a single MediaPlayer item +/// The MediaPlayer is then passed to any controller that needs to use it. +class PlaylistCarplayManager: NSObject { + private var carPlayStatusObservers = [Any]() + private let contentManager = MPPlayableContentManager.shared() + private var carPlayController: PlaylistCarplayController? + private weak var mediaPlayer: MediaPlayer? + private(set) var isCarPlayAvailable = false + + var currentlyPlayingItemIndex = -1 + var currentPlaylistItem: PlaylistInfo? + var browserController: BrowserViewController? + + // When Picture-In-Picture is enabled, we need to store a reference to the controller to keep it alive, otherwise if it deallocates, the system automatically kills Picture-In-Picture. + var playlistController: PlaylistViewController? + + // There can only ever be one instance of this class + // Because there can only be a single AudioSession and MediaPlayer + // in use at any given moment + static let shared = PlaylistCarplayManager() + + private override init() { + super.init() + + // We need to observe when CarPlay is connected + // That way, we can determine where the controls are coming from for Playlist + // OR determine where the AudioSession is outputting + + // We need to observe the audio route because sometimes the car will be disconnected + // and contentManager.context.endpointAvailable will still return true! + carPlayStatusObservers.append(NotificationCenter.default.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] _ in + + let hasCarPlay = AVAudioSession.sharedInstance().currentRoute.outputs.contains(where: { $0.portType == .carAudio }) + self?.attemptInterfaceConnection(isCarPlayAvailable: hasCarPlay) + }) + + // Using publisher for this crashes no matter what! + // The moment you call `sink` on the publisher, it will crash. + // Seems like a bug in iOS itself. + // We observe the contentManager.context.endpointAvailable to determine when to create + // a carplay handler + carPlayStatusObservers.append(contentManager.observe(\.context) { [weak self] contentManager, _ in + self?.carPlayStatusObservers.append(contentManager.context.observe(\.endpointAvailable) { [weak self] context, change in + self?.attemptInterfaceConnection(isCarPlayAvailable: context.endpointAvailable) + }) + }) + + // This is needed because the notifications for carplay doesn't get posted initial + // until you actually attempt to use the AudioSession or Context + let hasCarPlay = AVAudioSession.sharedInstance().currentRoute.outputs.contains(where: { $0.portType == .carAudio }) + let hasCarPlayEndpoint = contentManager.context.endpointAvailable + attemptInterfaceConnection(isCarPlayAvailable: hasCarPlay || hasCarPlayEndpoint) + } + + func getCarPlayController() -> PlaylistCarplayController { + // If there is no media player, create one, + // pass it to the car-play controller + let mediaPlayer = self.mediaPlayer ?? MediaPlayer() + let carPlayController = PlaylistCarplayController(browser: browserController, player: mediaPlayer, contentManager: contentManager) + self.mediaPlayer = mediaPlayer + return carPlayController + } + + func getPlaylistController(initialItem: PlaylistInfo?, initialItemPlaybackOffset: Double) -> PlaylistViewController { + // If there is no media player, create one, + // pass it to the play-list controller + let mediaPlayer = self.mediaPlayer ?? MediaPlayer() + + let playlistController = self.playlistController ?? + PlaylistViewController(mediaPlayer: mediaPlayer, + initialItem: initialItem, + initialItemPlaybackOffset: initialItemPlaybackOffset) + self.mediaPlayer = mediaPlayer + return playlistController + } + + func getPlaylistController(tab: Tab?, completion: @escaping (PlaylistViewController) -> Void) { + if let playlistController = self.playlistController { + return completion(playlistController) + } + + if let tab = tab, + let item = tab.playlistItem, + let webView = tab.webView, + let tag = tab.playlistItem?.tagId { + PlaylistHelper.getCurrentTime(webView: webView, nodeTag: tag) { [unowned self] currentTime in + DispatchQueue.main.async { + completion(self.getPlaylistController(initialItem: item, + initialItemPlaybackOffset: currentTime)) + } + } + } else { + return completion(getPlaylistController(initialItem: nil, + initialItemPlaybackOffset: 0.0)) + } + } + + private func attemptInterfaceConnection(isCarPlayAvailable: Bool) { + self.isCarPlayAvailable = isCarPlayAvailable + + // If there is no media player, create one, + // pass it to the carplay controller + if isCarPlayAvailable { + // Protect against reentrancy. + if carPlayController == nil { + carPlayController = self.getCarPlayController() + } + } else { + carPlayController = nil + mediaPlayer = nil + } + + // Sometimes the `endpointAvailable` WILL RETURN TRUE! + // Even when the car is NOT connected. + log.debug("CARPLAY CONNECTED: \(isCarPlayAvailable) -- \(contentManager.context.endpointAvailable)") + } +} diff --git a/Client/Frontend/Browser/Playlist/Cache/PlaylistDownloadManager.swift b/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistDownloadManager.swift similarity index 100% rename from Client/Frontend/Browser/Playlist/Cache/PlaylistDownloadManager.swift rename to Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistDownloadManager.swift diff --git a/Client/Frontend/Browser/Playlist/Cache/PlaylistManager.swift b/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistManager.swift similarity index 79% rename from Client/Frontend/Browser/Playlist/Cache/PlaylistManager.swift rename to Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistManager.swift index 2548ff4ef7d..4d9fe0153f5 100644 --- a/Client/Frontend/Browser/Playlist/Cache/PlaylistManager.swift +++ b/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistManager.swift @@ -5,8 +5,10 @@ import Foundation import AVFoundation -import Shared +import Combine import CoreData + +import Shared import Data import BraveShared @@ -23,12 +25,26 @@ protocol PlaylistManagerDelegate: AnyObject { class PlaylistManager: NSObject { static let shared = PlaylistManager() - weak var delegate: PlaylistManagerDelegate? private let downloadManager = PlaylistDownloadManager() private let frc = PlaylistItem.frc() private var didRestoreSession = false + // Observers + private let onContentWillChange = PassthroughSubject() + private let onContentDidChange = PassthroughSubject() + private let onObjectChange = PassthroughSubject<(object: Any, + indexPath: IndexPath?, + type: NSFetchedResultsChangeType, + newIndexPath: IndexPath?), Never>() + + private let onDownloadProgressUpdate = PassthroughSubject<(id: String, + percentComplete: Double), Never>() + private let onDownloadStateChanged = PassthroughSubject<(id: String, + state: PlaylistDownloadManager.DownloadState, + displayName: String?, + error: Error?), Never>() + private override init() { super.init() @@ -36,17 +52,42 @@ class PlaylistManager: NSObject { frc.delegate = self } + var contentWillChange: AnyPublisher { + onContentWillChange.eraseToAnyPublisher() + } + + var contentDidChange: AnyPublisher { + onContentDidChange.eraseToAnyPublisher() + } + + var objectDidChange: AnyPublisher<(object: Any, indexPath: IndexPath?, type: NSFetchedResultsChangeType, newIndexPath: IndexPath?), Never> { + onObjectChange.eraseToAnyPublisher() + } + + var downloadProgressUpdated: AnyPublisher<(id: String, percentComplete: Double), Never> { + onDownloadProgressUpdate.eraseToAnyPublisher() + } + + var downloadStateChanged: AnyPublisher<(id: String, state: PlaylistDownloadManager.DownloadState, displayName: String?, error: Error?), Never> { + onDownloadStateChanged.eraseToAnyPublisher() + } + var numberOfAssets: Int { frc.fetchedObjects?.count ?? 0 } - func itemAtIndex(_ index: Int) -> PlaylistInfo { - PlaylistInfo(item: frc.object(at: IndexPath(row: index, section: 0))) + func itemAtIndex(_ index: Int) -> PlaylistInfo? { + if index < numberOfAssets { + return PlaylistInfo(item: frc.object(at: IndexPath(row: index, section: 0))) + } + return nil } - func assetAtIndex(_ index: Int) -> AVURLAsset { - let item = itemAtIndex(index) - return asset(for: item.pageSrc, mediaSrc: item.src) + func assetAtIndex(_ index: Int) -> AVURLAsset? { + if let item = itemAtIndex(index) { + return asset(for: item.pageSrc, mediaSrc: item.src) + } + return nil } func index(of pageSrc: String) -> Int? { @@ -152,7 +193,7 @@ class PlaylistManager: NSObject { func download(item: PlaylistInfo) { guard downloadManager.downloadTask(for: item.pageSrc) == nil, let assetUrl = URL(string: item.src) else { return } - MediaResourceManager.getMimeType(assetUrl) { [weak self] mimeType in + PlaylistMediaStreamer.getMimeType(assetUrl) { [weak self] mimeType in guard let self = self, let mimeType = mimeType?.lowercased() else { return } if mimeType.contains("x-mpegurl") || mimeType.contains("application/vnd.apple.mpegurl") || mimeType.contains("mpegurl") { @@ -181,13 +222,13 @@ class PlaylistManager: NSObject { // That will cause zombie items. if deleteCache(item: item) { PlaylistItem.removeItem(item) - delegate?.onDownloadStateChanged(id: item.pageSrc, state: .invalid, displayName: nil, error: nil) + onDownloadStateChanged(id: item.pageSrc, state: .invalid, displayName: nil, error: nil) return true } return false } else { PlaylistItem.removeItem(item) - delegate?.onDownloadStateChanged(id: item.pageSrc, state: .invalid, displayName: nil, error: nil) + onDownloadStateChanged(id: item.pageSrc, state: .invalid, displayName: nil, error: nil) return true } } @@ -205,7 +246,7 @@ class PlaylistManager: NSObject { if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(atPath: url.path) PlaylistItem.updateCache(pageSrc: item.pageSrc, cachedData: nil) - delegate?.onDownloadStateChanged(id: item.pageSrc, state: .invalid, displayName: nil, error: nil) + onDownloadStateChanged(id: item.pageSrc, state: .invalid, displayName: nil, error: nil) } return true } catch { @@ -223,7 +264,7 @@ class PlaylistManager: NSObject { // 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` - (UIApplication.shared.delegate as? AppDelegate)?.playlistRestorationController = nil + PlaylistCarplayManager.shared.playlistController = nil guard let playlistItems = frc.fetchedObjects else { log.error("An error occured while fetching Playlist Objects") @@ -306,26 +347,26 @@ extension PlaylistManager { extension PlaylistManager: PlaylistDownloadManagerDelegate { func onDownloadProgressUpdate(id: String, percentComplete: Double) { - delegate?.onDownloadProgressUpdate(id: id, percentComplete: percentComplete) + onDownloadProgressUpdate.send((id: id, percentComplete: percentComplete)) } func onDownloadStateChanged(id: String, state: PlaylistDownloadManager.DownloadState, displayName: String?, error: Error?) { - delegate?.onDownloadStateChanged(id: id, state: state, displayName: displayName, error: error) + onDownloadStateChanged.send((id: id, state: state, displayName: displayName, error: error)) } } extension PlaylistManager: NSFetchedResultsControllerDelegate { func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { - delegate?.controllerDidChange(anObject, at: indexPath, for: type, newIndexPath: newIndexPath) + onObjectChange.send((object: anObject, indexPath: indexPath, type: type, newIndexPath: newIndexPath)) } func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - delegate?.controllerDidChangeContent() + onContentDidChange.send(()) } func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - delegate?.controllerWillChangeContent() + onContentWillChange.send(()) } } diff --git a/Client/Frontend/Browser/PlaylistToast.swift b/Client/Frontend/Browser/Playlist/Onboarding & Toast/PlaylistToast.swift similarity index 100% rename from Client/Frontend/Browser/PlaylistToast.swift rename to Client/Frontend/Browser/Playlist/Onboarding & Toast/PlaylistToast.swift diff --git a/Client/Frontend/Browser/Playlist/PlaylistMediaInfo.swift b/Client/Frontend/Browser/Playlist/PlaylistMediaInfo.swift deleted file mode 100644 index 3048dedc8c1..00000000000 --- a/Client/Frontend/Browser/Playlist/PlaylistMediaInfo.swift +++ /dev/null @@ -1,527 +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 -import MediaPlayer -import AVKit -import AVFoundation -import Shared -import Data - -private let log = Logger.browserLogger - -class PlaylistMediaInfo: NSObject { - private weak var playerView: VideoView? - private var webLoader: PlaylistWebLoader? - private var playerStatusObserver: PlaylistPlayerStatusObserver? - private var rateObserver: NSKeyValueObservation? - public var nowPlayingInfo: PlaylistInfo? { - didSet { - updateNowPlayingMediaInfo() - } - } - - public init(playerView: VideoView) { - self.playerView = playerView - super.init() - - MPRemoteCommandCenter.shared().pauseCommand.addTarget { [weak self] _ in - self?.playerView?.pause() - return .success - } - - MPRemoteCommandCenter.shared().playCommand.addTarget { [weak self] _ in - self?.playerView?.play() - return .success - } - - MPRemoteCommandCenter.shared().stopCommand.addTarget { [weak self] _ in - self?.playerView?.stop() - return .success - } - - MPRemoteCommandCenter.shared().changeRepeatModeCommand.addTarget { _ in - .success - } - - MPRemoteCommandCenter.shared().changeShuffleModeCommand.addTarget { _ in - .success - } - - MPRemoteCommandCenter.shared().previousTrackCommand.addTarget { [weak self] _ in - self?.playerView?.previous() - return .success - } - - MPRemoteCommandCenter.shared().nextTrackCommand.addTarget { [weak self] _ in - self?.playerView?.next() - return .success - } - - MPRemoteCommandCenter.shared().skipBackwardCommand.then { - $0.preferredIntervals = [NSNumber(value: 15.0)] - }.addTarget { [weak self] event in - guard let self = self, - let playerView = self.playerView, - let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed } - - let currentTime = playerView.player.currentTime() - playerView.seekBackwards() - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = Double(currentTime.seconds - event.interval) - return .success - } - - MPRemoteCommandCenter.shared().skipForwardCommand.then { - $0.preferredIntervals = [NSNumber(value: 15.0)] - }.addTarget { [weak self] event in - guard let self = self, - let playerView = self.playerView, - let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed } - - let currentTime = playerView.player.currentTime() - playerView.seekForwards() - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = Double(currentTime.seconds + event.interval) - return .success - } - - MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { [weak self] event in - if let event = event as? MPChangePlaybackPositionCommandEvent { - self?.playerView?.seek(to: event.positionTime) - } - return .success - } - - UIApplication.shared.beginReceivingRemoteControlEvents() - updateNowPlayingMediaInfo() - rateObserver = playerView.player.observe(\AVPlayer.rate, changeHandler: { [weak self] _, _ in - self?.updateNowPlayingMediaInfo() - }) - } - - deinit { - MPNowPlayingInfoCenter.default().nowPlayingInfo = nil - self.webLoader?.removeFromSuperview() - UIApplication.shared.endReceivingRemoteControlEvents() - } - - func updateNowPlayingMediaInfo() { - if let nowPlayingItem = self.nowPlayingInfo { - MPNowPlayingInfoCenter.default().nowPlayingInfo = [ - MPNowPlayingInfoPropertyMediaType: "Audio", - MPMediaItemPropertyTitle: nowPlayingItem.name, - MPMediaItemPropertyArtist: URL(string: nowPlayingItem.pageSrc)?.baseDomain ?? nowPlayingItem.pageSrc, - MPMediaItemPropertyPlaybackDuration: TimeInterval(nowPlayingItem.duration), - MPNowPlayingInfoPropertyPlaybackRate: Double(self.playerView?.player.rate ?? 1.0), - MPNowPlayingInfoPropertyPlaybackProgress: Float(0.0), - MPNowPlayingInfoPropertyElapsedPlaybackTime: Double(self.playerView?.player.currentTime().seconds ?? 0.0) - ] - } else { - MPNowPlayingInfoCenter.default().nowPlayingInfo = nil - } - } - - func updateNowPlayingMediaArtwork(image: UIImage?) { - if let image = image { - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ -> UIImage in - // Do not resize image here. - // According to Apple it isn't necessary to use expensive resize operations - return image - }) - } - } -} - -extension PlaylistMediaInfo: MPPlayableContentDelegate { - - enum MediaPlaybackError { - case expired - case error(Error) - case none - } - - func loadMediaItem(_ item: PlaylistInfo, index: Int, autoPlayEnabled: Bool = true, completion: @escaping (MediaPlaybackError) -> Void) { - self.nowPlayingInfo = item - self.playerStatusObserver = nil - self.playerView?.stop() - let cacheState = PlaylistManager.shared.state(for: item.pageSrc) - - if cacheState == .invalid { - // Fallback to web stream - let streamingFallback = { [weak self] in - guard let self = self else { - completion(.expired) - return - } - - self.webLoader?.removeFromSuperview() - self.webLoader = PlaylistWebLoader(handler: { [weak self] newItem in - guard let self = self else { return } - defer { - // Destroy the web loader when the callback is complete. - self.webLoader?.removeFromSuperview() - self.webLoader = nil - } - - if let newItem = newItem, let url = URL(string: newItem.src) { - let group = DispatchGroup() - group.enter() - self.playerView?.load(url: url, resourceDelegate: nil, autoPlayEnabled: autoPlayEnabled) { - group.leave() - } - - group.enter() - PlaylistItem.updateItem(newItem) { - group.leave() - } - - group.notify(queue: .main) { - completion(.none) - } - } else { - self.nowPlayingInfo = nil - self.updateNowPlayingMediaArtwork(image: nil) - completion(.expired) - } - }).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?.window?.insertSubview($0, at: 0) - } - - if let url = URL(string: item.pageSrc) { - self.webLoader?.load(url: url) - } else { - self.nowPlayingInfo = nil - self.updateNowPlayingMediaArtwork(image: nil) - completion(.error("Cannot Load Media")) - } - } - - // Determine if an item can be streamed and stream it directly - if !item.src.isEmpty, let url = URL(string: item.src) { - // Try to stream the asset from its url.. - MediaResourceManager.canStreamURL(url) { [weak self] canStream in - guard let self = self else { return } - - if canStream { - self.playerView?.seek(to: 0.0) - self.playerView?.load(url: url, resourceDelegate: nil, autoPlayEnabled: autoPlayEnabled, completion: { - log.debug("Playlist Item Loaded") - }) - - if let player = self.playerView?.player { - self.playerStatusObserver = PlaylistPlayerStatusObserver(player: player, onStatusChanged: { status in - self.playerStatusObserver = nil - - DispatchQueue.main.async { - if status == .failed { - self.nowPlayingInfo = nil - self.updateNowPlayingMediaArtwork(image: nil) - completion(.expired) - } else { - completion(.none) - } - } - }) - } else { - self.nowPlayingInfo = nil - self.updateNowPlayingMediaArtwork(image: nil) - completion(.expired) - } - } else { - // Stream failed so fallback to the webview - // It's possible the URL expired.. - streamingFallback() - } - } - } else { - // Fallback to the webview because there was no stream URL somehow.. - streamingFallback() - } - } else { - // Load from the cache since this item was downloaded before.. - let asset = PlaylistManager.shared.assetAtIndex(index) - self.playerView?.load(asset: asset, autoPlayEnabled: autoPlayEnabled) { - completion(.none) - } - } - } -} - -extension PlaylistMediaInfo { - - func thumbnailForURL(_ url: String) -> UIImage? { - guard let sourceURL = URL(string: url) else { - return nil - } - - let asset = AVAsset(url: sourceURL) - let imageGenerator = AVAssetImageGenerator(asset: asset) - let time = CMTimeMakeWithSeconds(2, preferredTimescale: 1) - - do { - let imageRef = try imageGenerator.copyCGImage(at: time, actualTime: nil) - return UIImage(cgImage: imageRef) - } catch { - log.error("Error copying thumbnail for playlist url: \(url) - \(error)") - } - return nil - } -} - -class PlaylistPlayerStatusObserver: NSObject { - private var context = 0 - private weak var player: AVPlayer? - private var item: AVPlayerItem? - private var onStatusChanged: (AVPlayerItem.Status) -> Void - private var currentItemObserver: NSKeyValueObservation? - private var itemStatusObserver: NSKeyValueObservation? - - init(player: AVPlayer, onStatusChanged: @escaping (AVPlayerItem.Status) -> Void) { - self.onStatusChanged = onStatusChanged - super.init() - - self.player = player - currentItemObserver = player.observe(\AVPlayer.currentItem, options: [.new], changeHandler: { [weak self] _, change in - guard let self = self else { return } - - if let newItem = change.newValue { - self.item = newItem - self.itemStatusObserver = newItem?.observe(\AVPlayerItem.status, options: [.new], changeHandler: { [weak self] _, change in - guard let self = self else { return } - - let status = change.newValue ?? .unknown - switch status { - case .readyToPlay: - log.debug("Player Item Status: Ready") - self.onStatusChanged(.readyToPlay) - case .failed: - log.debug("Player Item Status: Failed") - self.onStatusChanged(.failed) - case .unknown: - log.debug("Player Item Status: Unknown") - self.onStatusChanged(.unknown) - @unknown default: - assertionFailure("Unknown Switch Case for AVPlayerItemStatus") - } - }) - } - }) - } -} - -// A resource manager/downloader that is capable of handling HLS streams and content requests -// This is used to determine if a resource is streamable (HLS) and to be able to request chunks of that stream. -class MediaResourceManager: NSObject, AVAssetResourceLoaderDelegate { - - private var completion: ((Error?) -> Void)? - private var dataRequests = [AVAssetResourceLoadingRequest]() - private var contentInfoRequest: AVAssetResourceLoadingRequest? - private lazy var session = URLSession(configuration: .ephemeral, delegate: nil, delegateQueue: .main) - private var data = Data() - - init(_ completion: @escaping (Error?) -> Void) { - self.completion = completion - } - - func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { - // Load the content information.. - if let contentRequest = loadingRequest.contentInformationRequest { - guard let requestURL = loadingRequest.request.url else { return false } - guard MediaResourceManager.isShimmedURL(requestURL) else { return false } - let originalURL = MediaResourceManager.unShimURL(requestURL) - - // Request for byte-ranges.. - let request: URLRequest = { - let offset = loadingRequest.dataRequest?.currentOffset ?? 0 - let length = loadingRequest.dataRequest?.requestedLength ?? 0 - - var request = loadingRequest.request - request.url = originalURL - request.addValue("bytes=\(offset)-\(max(1, length - 1))", forHTTPHeaderField: "Range") - return request - }() - - self.session.dataTask(with: request) { [weak self] data, response, error in - guard let self = self else { return } - guard let response = response as? HTTPURLResponse else { - self.completion?("Invalid Response") - return - } - - if response.statusCode == 200 { - contentRequest.contentType = response.mimeType - contentRequest.contentLength = response.expectedContentLength - contentRequest.isByteRangeAccessSupported = response.expectedContentLength != -1 - - if let rangeString = response.allHeaderFields["Content-Range"] as? String, - let contentLength = rangeString.split(separator: "/").compactMap({ Int64($0) }).last { - contentRequest.contentLength = contentLength - } - - let acceptedRanges = (response.allHeaderFields["Accept-Ranges"] as? String)?.split(separator: ",") - if acceptedRanges?.map({ String($0).trim(" ") }).contains("bytes") == true { - contentRequest.isByteRangeAccessSupported = true - } else { - contentRequest.isByteRangeAccessSupported = false - } - } else { - self.completion?("Invalid Response") - self.completion = nil - return - } - - loadingRequest.finishLoading() - }.resume() - contentInfoRequest = loadingRequest - return true - } - - if let dataRequest = loadingRequest.dataRequest { - guard let requestURL = loadingRequest.request.url else { return false } - guard MediaResourceManager.isShimmedURL(requestURL) else { return false } - let originalURL = MediaResourceManager.unShimURL(requestURL) - - // Request for byte-ranges.. - let request: URLRequest = { - let offset = dataRequest.currentOffset - let length = dataRequest.requestedLength - - var request = URLRequest(url: originalURL) - request.cachePolicy = .reloadIgnoringLocalCacheData - request.addValue("bytes=\(offset)-\(max(1, length - 1))", forHTTPHeaderField: "Range") - return request - }() - - // Should use URLSessionDataDelegate to stream instead of downloading all chunks at once.. - self.session.dataTask(with: request) { [weak self] data, response, error in - guard let self = self else { return } - - if let data = data { - self.data.append(data) - } - - self.processPendingRequests() - - }.resume() - - dataRequests.append(loadingRequest) - return true - } - - return false - } - - func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForRenewalOfRequestedResource renewalRequest: AVAssetResourceRenewalRequest) -> Bool { - false - } - - func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) { - if contentInfoRequest == loadingRequest { - contentInfoRequest = nil - return - } - - dataRequests.removeAll(where: { loadingRequest == $0 }) - } - - func processPendingRequests() { - let requestsFulfilled = Set(self.dataRequests.compactMap { - $0.contentInformationRequest?.contentLength = Int64(self.data.count) - $0.contentInformationRequest?.isByteRangeAccessSupported = true - - if self.haveEnoughDataToFulfillRequest($0.dataRequest!) { - $0.finishLoading() - return $0 - } - return nil - }) - - self.dataRequests.removeAll(where: { requestsFulfilled.contains($0) }) - } - - func haveEnoughDataToFulfillRequest(_ dataRequest: AVAssetResourceLoadingDataRequest) -> Bool { - let requestedOffset = Int(dataRequest.requestedOffset) - let currentOffset = Int(dataRequest.currentOffset) - let requestedLength = dataRequest.requestedLength - - if currentOffset <= self.data.count { - let bytesToRespond = min(self.data.count - currentOffset, requestedLength) - let data = self.data.subdata(in: Range(uncheckedBounds: (currentOffset, currentOffset + bytesToRespond))) - dataRequest.respond(with: data) - return self.data.count >= requestedLength + requestedOffset - } - - return false - } -} - -extension MediaResourceManager { - static func shimURL(_ url: URL) -> URL { - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - guard let scheme = components?.scheme else { return url } - components?.scheme = "brave-media-resource" + scheme - return components?.url ?? url - } - - static func unShimURL(_ url: URL) -> URL { - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - guard let scheme = components?.scheme else { return url } - components?.scheme = scheme.replacingOccurrences(of: "brave-media-resource", with: "") - return components?.url ?? url - } - - static func isShimmedURL(_ url: URL) -> Bool { - let components = URLComponents(url: url, resolvingAgainstBaseURL: false) - guard let scheme = components?.scheme else { return false } - return scheme.hasPrefix("brave-media-resource") - } - - // Would be nice if AVPlayer could detect the mime-type from the URL for my delegate without a head request.. - // This function only exists because I can't figure out why videos from URLs don't play unless I explicitly specify a mime-type.. - static func canStreamURL(_ url: URL, _ completion: @escaping (Bool) -> Void) { - getMimeType(url) { mimeType in - if let mimeType = mimeType { - completion(!mimeType.isEmpty) - } else { - completion(false) - } - } - } - - static func getMimeType(_ url: URL, _ completion: @escaping (String?) -> Void) { - let request: URLRequest = { - var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10.0) - - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range - request.addValue("bytes=0-1", forHTTPHeaderField: "Range") - request.addValue(UUID().uuidString, forHTTPHeaderField: "X-Playback-Session-Id") - request.addValue(UserAgent.shouldUseDesktopMode ? UserAgent.desktop : UserAgent.mobile, forHTTPHeaderField: "User-Agent") - return request - }() - - URLSession(configuration: .ephemeral).dataTask(with: request) { data, response, error in - DispatchQueue.main.async { - if let error = error { - log.error("Error fetching MimeType for playlist item: \(url) - \(error)") - return completion(nil) - } - - if let response = response as? HTTPURLResponse, response.statusCode == 302 || response.statusCode >= 200 && response.statusCode <= 299 { - if let contentType = response.allHeaderFields["Content-Type"] as? String { - completion(contentType) - return - } else { - completion("video/*") - return - } - } - - completion(nil) - } - }.resume() - } -} diff --git a/Client/Frontend/Browser/Playlist/PlaylistViewController.swift b/Client/Frontend/Browser/Playlist/PlaylistViewController.swift deleted file mode 100644 index 736806cae17..00000000000 --- a/Client/Frontend/Browser/Playlist/PlaylistViewController.swift +++ /dev/null @@ -1,1644 +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 -import UIKit -import BraveShared -import Shared -import AVKit -import AVFoundation -import SDWebImage -import CoreData -import Data -import Combine - -private let log = Logger.browserLogger - -// MARK: - DisplayMode - -private enum DisplayMode { - case iPhoneLayout - case iPadLayout -} - -// MARK: PlaylistViewController - -class PlaylistViewController: UIViewController { - - // MARK: Properties - - private let splitController = UISplitViewController() - private let listController = ListController() - private let detailController = DetailController() - - init(initialItem: PlaylistInfo?, initialItemPlaybackOffset: Double) { - super.init(nibName: nil, bundle: nil) - - listController.initialItem = initialItem - listController.initialItemPlaybackOffset = initialItemPlaybackOffset - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - overrideUserInterfaceStyle = .dark - - splitController.do { - $0.viewControllers = [SettingsNavigationController(rootViewController: listController), - SettingsNavigationController(rootViewController: detailController)] - $0.delegate = self - $0.primaryEdge = PlayListSide(rawValue: Preferences.Playlist.listViewSide.value) == .left ? .leading : .trailing - $0.presentsWithGesture = false - $0.maximumPrimaryColumnWidth = 400 - $0.minimumPrimaryColumnWidth = 400 - } - - addChild(splitController) - view.addSubview(splitController.view) - - splitController.do { - $0.didMove(toParent: self) - $0.view.translatesAutoresizingMaskIntoConstraints = false - $0.view.snp.makeConstraints { - $0.edges.equalToSuperview() - } - } - - updateLayoutForOrientationChange() - - detailController.setVideoPlayer(listController.playerView) - detailController.navigationController?.setNavigationBarHidden(splitController.isCollapsed || traitCollection.horizontalSizeClass == .regular, animated: false) - - if UIDevice.isPhone { - if splitController.isCollapsed == false && traitCollection.horizontalSizeClass == .regular { - listController.updateLayoutForMode(.iPadLayout) - detailController.updateLayoutForMode(.iPadLayout) - } else { - listController.updateLayoutForMode(.iPhoneLayout) - detailController.updateLayoutForMode(.iPhoneLayout) - - // On iPhone Pro Max which displays like an iPad, we need to hide navigation bar. - if UIDevice.isPhone && UIDevice.current.orientation.isLandscape { - listController.onFullScreen() - } - } - } else { - listController.updateLayoutForMode(.iPadLayout) - detailController.updateLayoutForMode(.iPadLayout) - } - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - updateLayoutForOrientationChange() - } - - override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent - } - - private func updateLayoutForOrientationChange() { - if listController.playerView.isFullscreen { - splitController.preferredDisplayMode = .secondaryOnly - } else { - if UIDevice.current.orientation.isLandscape { - splitController.preferredDisplayMode = .secondaryOnly - } else { - splitController.preferredDisplayMode = .primaryOverlay - } - } - } - - fileprivate func onSidePanelStateChanged() { - detailController.onSidePanelStateChanged() - } - - fileprivate func onFullscreen() { - detailController.onFullScreen() - } - - fileprivate func onExitFullscreen() { - detailController.onExitFullScreen() - } -} - -// MARK: - UIAdaptivePresentationControllerDelegate - -extension PlaylistViewController: UIAdaptivePresentationControllerDelegate { - func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { - return .fullScreen - } -} - -// MARK: - UISplitViewControllerDelegate - -extension PlaylistViewController: UISplitViewControllerDelegate { - func splitViewControllerSupportedInterfaceOrientations(_ splitViewController: UISplitViewController) -> UIInterfaceOrientationMask { - return .allButUpsideDown - } - - func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool { - - // On iPhone, always display the iPhone layout (collapsed) no matter what. - // On iPad, we need to update both the list controller's layout (collapsed) and the detail controller's layout (collapsed). - listController.updateLayoutForMode(.iPhoneLayout) - detailController.setVideoPlayer(nil) - detailController.updateLayoutForMode(.iPhoneLayout) - return true - } - - func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? { - - // On iPhone, always display the iPad layout (expanded) when not in compact mode. - // On iPad, we need to update both the list controller's layout (expanded) and the detail controller's layout (expanded). - listController.updateLayoutForMode(.iPadLayout) - detailController.setVideoPlayer(listController.playerView) - detailController.updateLayoutForMode(.iPadLayout) - - if UIDevice.isPhone { - detailController.navigationController?.setNavigationBarHidden(true, animated: true) - } - - return detailController.navigationController ?? detailController - } -} - -// MARK: - ListController - -private class ListController: UIViewController { - // MARK: Constants - - struct Constants { - static let playListCellIdentifier = "playlistCellIdentifier" - static let tableRowHeight: CGFloat = 80 - static let tableHeaderHeight: CGFloat = 11 - } - - // MARK: Properties - public var initialItem: PlaylistInfo? - public var initialItemPlaybackOffset = 0.0 - - public let playerView = VideoView() - private lazy var mediaInfo = PlaylistMediaInfo(playerView: playerView) - private var currentlyPlayingItemIndex = -1 - private var autoPlayEnabled = true - private var playerController: AVPlayerViewController? - private var playerStatusObserver: PlaylistPlayerStatusObserver? - - private lazy var activityIndicator = UIActivityIndicatorView(style: .medium).then { - $0.isHidden = true - $0.hidesWhenStopped = true - } - - private let tableView = UITableView(frame: .zero, style: .grouped).then { - $0.backgroundView = UIView() - $0.backgroundColor = .braveBackground - $0.separatorColor = .clear - $0.allowsSelectionDuringEditing = true - } - - private let formatter = DateComponentsFormatter().then { - $0.allowedUnits = [.day, .hour, .minute, .second] - $0.unitsStyle = .abbreviated - $0.maximumUnitCount = 2 - } - - init() { - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - if let playTime = playerView.player.currentItem?.currentTime(), - Preferences.Playlist.playbackLeftOff.value { - Preferences.Playlist.lastPlayedItemTime.value = playTime.seconds - } else { - Preferences.Playlist.lastPlayedItemTime.value = 0.0 - } - - playerStatusObserver = nil - playerView.pictureInPictureController?.delegate = nil - playerView.pictureInPictureController?.stopPictureInPicture() - playerView.stop() - - NotificationCenter.default.removeObserver(self) - - if let delegate = UIApplication.shared.delegate as? AppDelegate { - if UIDevice.isIpad { - playerView.attachLayer() - } - delegate.playlistRestorationController = nil - } - } - - override func viewDidLoad() { - super.viewDidLoad() - - PlaylistManager.shared.delegate = self - - setTheme() - setup() - setupNotifications() - - fetchResults() - } - - // MARK: Internal - - private func setTheme() { - title = Strings.PlayList.playListSectionTitle - - view.backgroundColor = .braveBackground - navigationController?.do { - let appearance = UINavigationBarAppearance() - appearance.configureWithTransparentBackground() - appearance.titleTextAttributes = [.foregroundColor: UIColor.white] - appearance.backgroundColor = .braveBackground - - $0.navigationBar.standardAppearance = appearance - $0.navigationBar.barTintColor = UIColor.braveBackground - $0.navigationBar.tintColor = .white - } - } - - private func setup () { - tableView.do { - $0.register(PlaylistCell.self, forCellReuseIdentifier: Constants.playListCellIdentifier) - $0.dataSource = self - $0.delegate = self - $0.dragDelegate = self - $0.dropDelegate = self - $0.dragInteractionEnabled = true - } - - playerView.delegate = self - } - - private func setupNotifications() { - // Instead of observing every single second the player's time changes, - // we observe when the user terminates the app to update the last playing time. - // We do the same in the deinit of this controller. - // This is necessary because `deinit` isn't called on termination and it would be an insane - // performance issue to write to preferences every time the player's time changes. - - let updatedAppStatuses = [ - UIApplication.willResignActiveNotification, - UIApplication.willTerminateNotification, - UIApplication.didEnterBackgroundNotification - ] - - updatedAppStatuses.forEach({ - NotificationCenter.default.addObserver(self, selector: #selector(updateLastPlayedItemTime(_:)), name: $0, object: nil) - }) - } - - @objc - func updateLastPlayedItemTime(_ notification: Notification) { - if let playTime = playerView.player.currentItem?.currentTime(), - Preferences.Playlist.playbackLeftOff.value { - Preferences.Playlist.lastPlayedItemTime.value = playTime.seconds - } else { - Preferences.Playlist.lastPlayedItemTime.value = 0.0 - } - } - - private func fetchResults() { - playerView.setControlsEnabled(playerView.player.currentItem != nil) - updateTableBackgroundView() - - let initialItem = self.initialItem - let initialItemOffset = self.initialItemPlaybackOffset - self.initialItem = nil - self.initialItemPlaybackOffset = 0.0 - - DispatchQueue.main.async { - PlaylistManager.shared.reloadData() - self.tableView.reloadData() - - let lastPlayedItemUrl = initialItem?.pageSrc ?? Preferences.Playlist.lastPlayedItemUrl.value - let lastPlayedItemTime = initialItem != nil ? initialItemOffset : Preferences.Playlist.lastPlayedItemTime.value - - self.autoPlayEnabled = initialItem != nil ? true : Preferences.Playlist.firstLoadAutoPlay.value - - if PlaylistManager.shared.numberOfAssets > 0 { - self.playerView.setControlsEnabled(true) - - if let lastPlayedItemUrl = lastPlayedItemUrl, let index = PlaylistManager.shared.index(of: lastPlayedItemUrl) { - let indexPath = IndexPath(row: index, section: 0) - - self.playItem(at: indexPath, completion: { [weak self] error in - guard let self = self else { return } - - switch error { - case .error(let err): - log.error(err) - self.displayLoadingResourceError() - case .expired: - let item = PlaylistManager.shared.itemAtIndex(indexPath.row) - self.displayExpiredResourceError(item: item) - case .none: - let seekLastPlayedItem = { [weak self] in - guard let self = self else { return } - let item = PlaylistManager.shared.itemAtIndex(indexPath.row) - - if item.pageSrc == lastPlayedItemUrl && - lastPlayedItemTime > 0.0 && - Preferences.Playlist.playbackLeftOff.value { - self.playerView.seek(to: lastPlayedItemTime) - } - - self.updateLastPlayedItem(indexPath: indexPath) - } - - if self.playerView.player.status == .readyToPlay { - seekLastPlayedItem() - } else { - self.playerStatusObserver = PlaylistPlayerStatusObserver(player: self.playerView.player, onStatusChanged: { [weak self] status in - guard let self = self else { return } - - log.debug("Player Status: \(status)") - - seekLastPlayedItem() - self.playerStatusObserver = nil - }) - } - } - }) - } else { - self.tableView.delegate?.tableView?(self.tableView, didSelectRowAt: IndexPath(row: 0, section: 0)) - } - - self.autoPlayEnabled = true - } - - self.updateTableBackgroundView() - } - } - - // MARK: Actions - - @objc - private func onExit(_ button: UIBarButtonItem) { - dismiss(animated: true, completion: nil) - } - - override var preferredStatusBarStyle: UIStatusBarStyle { - .lightContent - } - - public func updateLayoutForMode(_ mode: DisplayMode) { - navigationItem.rightBarButtonItem = nil - - if mode == .iPhoneLayout { - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(onExit(_:))) - - playerView.setSidePanelHidden(true) - - // If the player view is in fullscreen, we should NOT change the tableView layout on rotation. - view.addSubview(tableView) - view.addSubview(playerView) - playerView.addSubview(activityIndicator) - - if !playerView.isFullscreen { - if UIDevice.current.orientation.isLandscape && UIDevice.isPhone { - playerView.setExitButtonHidden(false) - playerView.setFullscreenButtonHidden(true) - playerView.snp.remakeConstraints { - $0.edges.equalTo(view.snp.edges) - } - - activityIndicator.snp.remakeConstraints { - $0.center.equalToSuperview() - } - } else { - playerView.setFullscreenButtonHidden(false) - playerView.setExitButtonHidden(true) - let videoPlayerHeight = (1.0 / 3.0) * (UIScreen.main.bounds.width > UIScreen.main.bounds.height ? UIScreen.main.bounds.width : UIScreen.main.bounds.height) - - tableView.do { - $0.contentInset = UIEdgeInsets(top: videoPlayerHeight, left: 0.0, bottom: view.safeAreaInsets.bottom, right: 0.0) - $0.scrollIndicatorInsets = $0.contentInset - $0.contentOffset = CGPoint(x: 0.0, y: -videoPlayerHeight) - $0.isHidden = false - } - - playerView.snp.remakeConstraints { - $0.top.equalTo(view.safeArea.top) - $0.leading.trailing.equalToSuperview() - $0.height.equalTo(videoPlayerHeight) - } - - activityIndicator.snp.remakeConstraints { - $0.center.equalToSuperview() - } - - tableView.snp.remakeConstraints { - $0.edges.equalToSuperview() - } - - // On iPhone-8, 14.4, I need to scroll the tableView after setting its contentOffset and contentInset - // Otherwise the layout is broken when exiting fullscreen in portrait mode. - if PlaylistManager.shared.numberOfAssets > 0 { - tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true) - } - } - } else { - playerView.snp.remakeConstraints { - $0.edges.equalToSuperview() - } - - activityIndicator.snp.remakeConstraints { - $0.center.equalToSuperview() - } - } - } else { - if splitViewController?.isCollapsed == true { - playerView.setFullscreenButtonHidden(false) - playerView.setExitButtonHidden(true) - playerView.setSidePanelHidden(true) - } else { - playerView.setFullscreenButtonHidden(true) - playerView.setExitButtonHidden(false) - playerView.setSidePanelHidden(false) - } - - view.addSubview(tableView) - playerView.addSubview(activityIndicator) - - tableView.do { - $0.contentInset = .zero - $0.scrollIndicatorInsets = $0.contentInset - $0.contentOffset = .zero - } - - activityIndicator.snp.remakeConstraints { - $0.center.equalToSuperview() - } - - tableView.snp.remakeConstraints { - $0.edges.equalToSuperview() - } - } - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - if UIDevice.isPhone && splitViewController?.isCollapsed == true { - updateLayoutForMode(.iPhoneLayout) - - if !playerView.isFullscreen { - navigationController?.setNavigationBarHidden(UIDevice.current.orientation.isLandscape, animated: true) - } - } - } -} - -// MARK: UITableViewDataSource - -extension ListController: UITableViewDataSource { - private func getRelativeDateFormat(date: Date) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .abbreviated - formatter.dateTimeStyle = .numeric - return formatter.localizedString(fromTimeInterval: date.timeIntervalSinceNow) - } - - private func getAssetDuration(item: PlaylistInfo, _ completion: @escaping (TimeInterval?, AVAsset?) -> Void) -> PlaylistAssetFetcher? { - let tolerance: Double = 0.00001 - let distance = abs(item.duration.distance(to: 0.0)) - - // If the database duration is live/indefinite - if item.duration.isInfinite || - abs(item.duration.distance(to: TimeInterval.greatestFiniteMagnitude)) < tolerance { - completion(TimeInterval.infinity, nil) - return nil - } - - // If the database duration is 0.0 - if distance >= tolerance { - // Return the database duration - completion(item.duration, nil) - return nil - } - - guard let index = PlaylistManager.shared.index(of: item.pageSrc) else { - completion(item.duration, nil) // Return the database duration - return nil - } - - // Attempt to retrieve the duration from the Asset file - let asset = PlaylistManager.shared.assetAtIndex(index) - - // Accessing tracks blocks the main-thread if not already loaded - // So we first need to check the track status before attempting to access it! - var error: NSError? - let trackStatus = asset.statusOfValue(forKey: "tracks", error: &error) - if let error = error { - log.error("AVAsset.statusOfValue error occurred: \(error)") - } - - if trackStatus == .loaded { - if !asset.tracks.isEmpty, - let track = asset.tracks(withMediaType: .video).first ?? - asset.tracks(withMediaType: .audio).first { - if track.timeRange.duration.isIndefinite { - completion(TimeInterval.infinity, nil) - } else { - completion(track.timeRange.duration.seconds, asset) - } - return nil - } - } else { - log.debug("AVAsset.statusOfValue not loaded. Status: \(trackStatus)") - } - - // Accessing duration or commonMetadata blocks the main-thread if not already loaded - // So we first need to check the track status before attempting to access it! - let durationStatus = asset.statusOfValue(forKey: "duration", error: &error) - if let error = error { - log.error("AVAsset.statusOfValue error occurred: \(error)") - } - - if durationStatus == .loaded { - // If it's live/indefinite - if asset.duration.isIndefinite { - completion(TimeInterval.infinity, asset) - return nil - } - - // If it's a valid duration - if abs(asset.duration.seconds.distance(to: 0.0)) >= tolerance { - completion(asset.duration.seconds, asset) - return nil - } - } else { - log.debug("AVAsset.statusOfValue not loaded. Status: \(durationStatus)") - } - - // We can't get the duration synchronously so we need to let the AVAsset load the media item - // and hopefully we get a valid duration from that. - asset.loadValuesAsynchronously(forKeys: ["playable", "tracks", "duration"]) { - var error: NSError? - let trackStatus = asset.statusOfValue(forKey: "tracks", error: &error) - if let error = error { - log.error("AVAsset.statusOfValue error occurred: \(error)") - } - - let durationStatus = asset.statusOfValue(forKey: "tracks", error: &error) - if let error = error { - log.error("AVAsset.statusOfValue error occurred: \(error)") - } - - if trackStatus == .cancelled || durationStatus == .cancelled { - return - } - - if trackStatus == .failed && durationStatus == .failed, let error = error { - if error.code == NSURLErrorNoPermissionsToReadFile { - // Media item is expired.. permission is denied - log.debug("Playlist Media Item Expired: \(item.pageSrc)") - - ensureMainThread { - completion(nil, nil) - } - } else { - log.error("An unknown error occurred while attempting to fetch track and duration information: \(error)") - - ensureMainThread { - completion(nil, nil) - } - } - - return - } - - var duration: CMTime = .zero - if trackStatus == .loaded { - if let track = asset.tracks(withMediaType: .video).first ?? asset.tracks(withMediaType: .audio).first { - duration = track.timeRange.duration - } else { - duration = asset.duration - } - } else if durationStatus == .loaded { - duration = asset.duration - } - - ensureMainThread { - if duration.isIndefinite { - completion(TimeInterval.infinity, asset) - } else if abs(duration.seconds.distance(to: 0.0)) > tolerance { - let newItem = PlaylistInfo(name: item.name, - src: item.src, - pageSrc: item.pageSrc, - pageTitle: item.pageTitle, - mimeType: item.mimeType, - duration: duration.seconds, - detected: item.detected, - dateAdded: item.dateAdded, - tagId: item.tagId) - - PlaylistItem.updateItem(newItem) { - completion(duration.seconds, asset) - } - } else { - completion(duration.seconds, asset) - } - } - } - - return PlaylistAssetFetcher(asset: asset) - } - - private func getAssetDurationFormatted(item: PlaylistInfo, _ completion: @escaping (String) -> Void) -> PlaylistAssetFetcher? { - return getAssetDuration(item: item) { [weak self] duration, asset in - guard let self = self else { return } - - let domain = URL(string: item.pageSrc)?.baseDomain ?? "0s" - if let duration = duration { - if duration.isInfinite { - // Live video/audio - completion(Strings.PlayList.playlistLiveMediaStream) - } else if abs(duration.distance(to: 0.0)) > 0.00001 { - completion(self.formatter.string(from: duration) ?? domain) - } else { - completion(domain) - } - } else { - // Media Item is expired or some sort of error occurred retrieving its duration - // Whatever the reason, we mark it as expired now - completion(Strings.PlayList.expiredLabelTitle) - } - } - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - PlaylistManager.shared.numberOfAssets - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - Constants.tableRowHeight - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - Constants.tableHeaderHeight - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: Constants.playListCellIdentifier, for: indexPath) as? PlaylistCell else { - return UITableViewCell() - } - - return cell - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard let cell = cell as? PlaylistCell else { - return - } - - cell.prepareForDisplay() - let item = PlaylistManager.shared.itemAtIndex(indexPath.row) - let domain = URL(string: item.pageSrc)?.baseDomain ?? "0s" - - cell.do { - $0.selectionStyle = .none - $0.titleLabel.text = item.name - $0.detailLabel.text = domain - $0.contentView.backgroundColor = .clear - $0.backgroundColor = .clear - $0.thumbnailView.image = nil - $0.thumbnailView.backgroundColor = .black - } - - let cacheState = PlaylistManager.shared.state(for: item.pageSrc) - switch cacheState { - case .inProgress: - cell.durationFetcher = getAssetDurationFormatted(item: item) { - cell.detailLabel.text = "\($0) - \(Strings.PlayList.savingForOfflineLabelTitle)" - } - case .downloaded: - if let itemSize = PlaylistManager.shared.sizeOfDownloadedItem(for: item.pageSrc) { - cell.durationFetcher = getAssetDurationFormatted(item: item) { - cell.detailLabel.text = "\($0) - \(itemSize)" - } - } else { - cell.durationFetcher = getAssetDurationFormatted(item: item) { - cell.detailLabel.text = "\($0) - \(Strings.PlayList.savedForOfflineLabelTitle)" - } - } - case .invalid: - cell.durationFetcher = getAssetDurationFormatted(item: item) { - cell.detailLabel.text = $0 - } - } - - // Load the HLS/Media thumbnail. If it fails, fall-back to favIcon - loadThumbnail(item: item, cell: cell) - } - - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let headerView = UIView() - headerView.backgroundColor = .clear - - return headerView - } - - // MARK: - Thumbnail - - private func loadThumbnail(item: PlaylistInfo, cell: PlaylistCell) { - guard let url = URL(string: item.src) else { return } - - cell.thumbnailActivityIndicator.startAnimating() - if let cachedImage = SDImageCache.shared.imageFromCache(forKey: url.absoluteString) { - cell.thumbnailView.image = cachedImage - cell.thumbnailView.backgroundColor = .black - cell.thumbnailView.contentMode = .scaleAspectFit - cell.thumbnailActivityIndicator.stopAnimating() - cell.thumbnailGenerator = nil - return - } - - // Loading from Cache failed, attempt to fetch HLS thumbnail - cell.thumbnailActivityIndicator.startAnimating() - cell.thumbnailGenerator = HLSThumbnailGenerator(url: url, time: 3, completion: { [weak self, weak cell] image, error in - guard let self = self, let cell = cell else { return } - - cell.thumbnailGenerator = nil - cell.thumbnailView.stopAnimating() - log.error(error) - - if let image = image { - cell.thumbnailView.image = image - cell.thumbnailView.backgroundColor = .black - cell.thumbnailView.contentMode = .scaleAspectFit - cell.thumbnailGenerator = nil - SDImageCache.shared.store(image, forKey: url.absoluteString, completion: nil) - } else { - // We can fall back to AVAssetImageGenerator or FavIcon - self.loadThumbnailFallbackImage(item: item, cell: cell) - } - }) - } - - // Fall back to AVAssetImageGenerator - // If that fails, fallback to FavIconFetcher - private func loadThumbnailFallbackImage(item: PlaylistInfo, cell: PlaylistCell) { - guard let url = URL(string: item.src) else { return } - - let time = CMTimeMake(value: 3, timescale: 1) - cell.thumbnailActivityIndicator.startAnimating() - cell.imageAssetGenerator = AVAssetImageGenerator(asset: AVAsset(url: url)) - cell.imageAssetGenerator?.appliesPreferredTrackTransform = false - cell.imageAssetGenerator?.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { [weak cell] _, cgImage, _, result, error in - guard let cell = cell else { return } - - cell.imageAssetGenerator = nil - if result == .succeeded, let cgImage = cgImage { - let image = UIImage(cgImage: cgImage) - - DispatchQueue.main.async { - cell.thumbnailActivityIndicator.stopAnimating() - cell.thumbnailView.image = image - cell.thumbnailView.backgroundColor = .black - cell.thumbnailView.contentMode = .scaleAspectFit - SDImageCache.shared.store(image, forKey: url.absoluteString, completion: nil) - } - } else { - guard let url = URL(string: item.pageSrc) else { return } - - DispatchQueue.main.async { - cell.thumbnailActivityIndicator.stopAnimating() - cell.thumbnailView.cancelFaviconLoad() - cell.thumbnailView.clearMonogramFavicon() - cell.thumbnailView.contentMode = .scaleAspectFit - cell.thumbnailView.image = FaviconFetcher.defaultFaviconImage - cell.thumbnailActivityIndicator.startAnimating() - - let domain = Domain.getOrCreate(forUrl: url, persistent: false) - cell.thumbnailView.loadFavicon(for: url, domain: domain) { [weak cell] in - guard let cell = cell else { return } - cell.thumbnailActivityIndicator.stopAnimating() - } - } - } - } - } -} - -// MARK: UITableViewDelegate - -extension ListController: UITableViewDelegate { - - func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - - if indexPath.row < 0 || indexPath.row >= PlaylistManager.shared.numberOfAssets { - return nil - } - - let currentItem = PlaylistManager.shared.itemAtIndex(indexPath.row) - let cacheState = PlaylistManager.shared.state(for: currentItem.pageSrc) - - let cacheAction = UIContextualAction(style: .normal, title: nil, handler: { [weak self] (action, view, completionHandler) in - guard let self = self else { return } - - switch cacheState { - case .inProgress: - PlaylistManager.shared.cancelDownload(item: currentItem) - self.tableView.reloadRows(at: [indexPath], with: .automatic) - case .invalid: - if PlaylistManager.shared.isDiskSpaceEncumbered() { - let style: UIAlertController.Style = UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet - let alert = UIAlertController( - title: Strings.PlayList.playlistDiskSpaceWarningTitle, message: Strings.PlayList.playlistDiskSpaceWarningMessage, preferredStyle: style) - - alert.addAction(UIAlertAction(title: Strings.OKString, style: .default, handler: { _ in - PlaylistManager.shared.download(item: currentItem) - self.tableView.reloadRows(at: [indexPath], with: .automatic) - })) - - alert.addAction(UIAlertAction(title: Strings.CancelString, style: .cancel, handler: nil)) - self.present(alert, animated: true, completion: nil) - } else { - PlaylistManager.shared.download(item: currentItem) - self.tableView.reloadRows(at: [indexPath], with: .automatic) - } - case .downloaded: - let style: UIAlertController.Style = UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet - let alert = UIAlertController( - title: Strings.PlayList.removePlaylistOfflineDataAlertTitle, message: Strings.PlayList.removePlaylistOfflineDataAlertMessage, preferredStyle: style) - - alert.addAction(UIAlertAction(title: Strings.PlayList.removeActionButtonTitle, style: .destructive, handler: { _ in - PlaylistManager.shared.deleteCache(item: currentItem) - self.tableView.reloadRows(at: [indexPath], with: .automatic) - })) - - alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel, handler: nil)) - - self.present(alert, animated: true, completion: nil) - } - - completionHandler(true) - }) - - let deleteAction = UIContextualAction(style: .normal, title: nil, handler: { [weak self] (action, view, completionHandler) in - guard let self = self else { return } - - let style: UIAlertController.Style = UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet - let alert = UIAlertController( - title: Strings.PlayList.removePlaylistVideoAlertTitle, message: Strings.PlayList.removePlaylistVideoAlertMessage, preferredStyle: style) - - alert.addAction(UIAlertAction(title: Strings.PlayList.removeActionButtonTitle, style: .destructive, handler: { _ in - PlaylistManager.shared.delete(item: currentItem) - - if self.currentlyPlayingItemIndex == indexPath.row { - self.currentlyPlayingItemIndex = -1 - self.mediaInfo.nowPlayingInfo = nil - self.mediaInfo.updateNowPlayingMediaArtwork(image: nil) - - self.updateTableBackgroundView() - self.playerView.resetVideoInfo() - self.activityIndicator.stopAnimating() - self.playerView.stop() - } - })) - - alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel, handler: nil)) - self.present(alert, animated: true, completion: nil) - - completionHandler(true) - }) - - cacheAction.image = cacheState == .invalid ? #imageLiteral(resourceName: "playlist_download") : #imageLiteral(resourceName: "playlist_delete_download") - cacheAction.backgroundColor = #colorLiteral(red: 0.4509803922, green: 0.4784313725, blue: 0.8705882353, alpha: 1) - - deleteAction.image = #imageLiteral(resourceName: "playlist_delete_item") - deleteAction.backgroundColor = #colorLiteral(red: 0.9176470588, green: 0.2274509804, blue: 0.05098039216, alpha: 1) - - return UISwipeActionsConfiguration(actions: [deleteAction, cacheAction]) - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if tableView.isEditing { - tableView.setEditing(false, animated: true) - return - } - - playItem(at: indexPath, completion: { [weak self] error in - guard let self = self else { return } - - switch error { - case .error(let err): - log.error(err) - self.displayLoadingResourceError() - case .expired: - let item = PlaylistManager.shared.itemAtIndex(indexPath.row) - self.displayExpiredResourceError(item: item) - case .none: - self.updateLastPlayedItem(indexPath: indexPath) - } - }) - } - - private func playItem(at indexPath: IndexPath, completion: ((PlaylistMediaInfo.MediaPlaybackError) -> Void)?) { - if indexPath.row >= PlaylistManager.shared.numberOfAssets { - return - } - - activityIndicator.startAnimating() - activityIndicator.isHidden = false - currentlyPlayingItemIndex = indexPath.row - - let selectedCell = tableView.cellForRow(at: indexPath) as? PlaylistCell - - let item = PlaylistManager.shared.itemAtIndex(indexPath.row) - playerView.setVideoInfo(videoDomain: item.pageSrc, videoTitle: item.pageTitle) - mediaInfo.updateNowPlayingMediaArtwork(image: selectedCell?.thumbnailView.image) - - playerView.stop() - - mediaInfo.loadMediaItem(item, index: indexPath.row, autoPlayEnabled: autoPlayEnabled) { [weak self] error in - guard let self = self else { return } - defer { completion?(error) } - self.activityIndicator.stopAnimating() - - switch error { - case .error: - break - - case .expired: - selectedCell?.detailLabel.text = Strings.PlayList.expiredLabelTitle - - case .none: - let mediaItem = self.playerView.player.currentItem ?? self.playerView.pendingMediaItem - log.debug("Playing Live Video: \(mediaItem?.duration.isIndefinite ?? false)") - } - } - } - - private func updateLastPlayedItem(indexPath: IndexPath) { - let item = PlaylistManager.shared.itemAtIndex(indexPath.row) - Preferences.Playlist.lastPlayedItemUrl.value = item.pageSrc - - if let playTime = self.playerView.player.currentItem?.currentTime(), - Preferences.Playlist.playbackLeftOff.value { - Preferences.Playlist.lastPlayedItemTime.value = playTime.seconds - } else { - Preferences.Playlist.lastPlayedItemTime.value = 0.0 - } - } - - private func displayExpiredResourceError(item: PlaylistInfo) { - let alert = UIAlertController(title: Strings.PlayList.expiredAlertTitle, - message: Strings.PlayList.expiredAlertDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: Strings.PlayList.reopenButtonTitle, style: .default, handler: { _ in - - if let url = URL(string: item.pageSrc) { - self.dismiss(animated: true, completion: nil) - (UIApplication.shared.delegate as? AppDelegate)?.browserViewController.openURLInNewTab(url, isPrivileged: false) - } - })) - alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel, handler: nil)) - self.present(alert, animated: true, completion: nil) - } - - private func displayLoadingResourceError() { - let alert = UIAlertController( - title: Strings.PlayList.sorryAlertTitle, message: Strings.PlayList.loadResourcesErrorAlertDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: Strings.PlayList.okayButtonTitle, style: .default, handler: nil)) - - self.present(alert, animated: true, completion: nil) - } -} - -// MARK: - Reordering of cells - -extension ListController: UITableViewDragDelegate, UITableViewDropDelegate { - func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { - true - } - - func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - .none - } - - func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { - false - } - - func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { - PlaylistManager.shared.reorderItems(from: sourceIndexPath, to: destinationIndexPath) { - PlaylistManager.shared.reloadData() - } - } - - func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { - let item = PlaylistManager.shared.itemAtIndex(indexPath.row) - let dragItem = UIDragItem(itemProvider: NSItemProvider()) - dragItem.localObject = item - return [dragItem] - } - - func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal { - - var dropProposal = UITableViewDropProposal(operation: .cancel) - guard session.items.count == 1 else { return dropProposal } - - if tableView.hasActiveDrag { - dropProposal = UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) - } - return dropProposal - } - - func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) { - guard let sourceIndexPath = coordinator.items.first?.sourceIndexPath else { return } - let destinationIndexPath: IndexPath - if let indexPath = coordinator.destinationIndexPath { - destinationIndexPath = indexPath - } else { - let section = tableView.numberOfSections - 1 - let row = tableView.numberOfRows(inSection: section) - destinationIndexPath = IndexPath(row: row, section: section) - } - - if coordinator.proposal.operation == .move { - guard let item = coordinator.items.first else { return } - _ = coordinator.drop(item.dragItem, toRowAt: destinationIndexPath) - tableView.moveRow(at: sourceIndexPath, to: destinationIndexPath) - } - } - - func tableView(_ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? { - guard let cell = tableView.cellForRow(at: indexPath) as? PlaylistCell else { return nil } - - let preview = UIDragPreviewParameters() - preview.visiblePath = UIBezierPath(roundedRect: cell.contentView.frame, cornerRadius: 12.0) - preview.backgroundColor = slightlyLighterColour(color: UIColor.braveBackground) - return preview - } - - func tableView(_ tableView: UITableView, dropPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? { - guard let cell = tableView.cellForRow(at: indexPath) as? PlaylistCell else { return nil } - - let preview = UIDragPreviewParameters() - preview.visiblePath = UIBezierPath(roundedRect: cell.contentView.frame, cornerRadius: 12.0) - preview.backgroundColor = slightlyLighterColour(color: UIColor.braveBackground) - return preview - } - - func tableView(_ tableView: UITableView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool { - true - } - - private func slightlyLighterColour(color: UIColor) -> UIColor { - let desaturation: CGFloat = 0.5 - var h: CGFloat = 0, s: CGFloat = 0 - var b: CGFloat = 0, a: CGFloat = 0 - - guard color.getHue(&h, saturation: &s, brightness: &b, alpha: &a) else {return color} - - return UIColor(hue: h, - saturation: max(s - desaturation, 0.0), - brightness: b, - alpha: a) - } -} - -extension ListController: UIGestureRecognizerDelegate { - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - return !tableView.isEditing - } -} - -// MARK: VideoViewDelegate - -extension ListController: VideoViewDelegate { - func onPreviousTrack(isUserInitiated: Bool) { - if currentlyPlayingItemIndex <= 0 { - return - } - - let index = currentlyPlayingItemIndex - 1 - if index < PlaylistManager.shared.numberOfAssets { - let indexPath = IndexPath(row: index, section: 0) - playItem(at: indexPath) { [weak self] error in - guard let self = self else { return } - switch error { - case .error(let err): - log.error(err) - self.displayLoadingResourceError() - case .expired: - let item = PlaylistManager.shared.itemAtIndex(index) - self.displayExpiredResourceError(item: item) - case .none: - self.currentlyPlayingItemIndex = index - self.updateLastPlayedItem(indexPath: indexPath) - } - } - } - } - - func onNextTrack(isUserInitiated: Bool) { - let assetCount = PlaylistManager.shared.numberOfAssets - let isAtEnd = currentlyPlayingItemIndex >= assetCount - 1 - var index = currentlyPlayingItemIndex - - switch playerView.repeatState { - case .none: - if isAtEnd { - playerView.pictureInPictureController?.delegate = nil - playerView.pictureInPictureController?.stopPictureInPicture() - playerView.stop() - - if let delegate = UIApplication.shared.delegate as? AppDelegate { - if UIDevice.isIpad { - playerView.attachLayer() - } - delegate.playlistRestorationController = nil - } - return - } - index += 1 - case .repeatOne: - playerView.seek(to: 0.0) - playerView.play() - return - case .repeatAll: - index = isAtEnd ? 0 : index + 1 - } - - if index >= 0 { - let indexPath = IndexPath(row: index, section: 0) - playItem(at: indexPath) { [weak self] error in - guard let self = self else { return } - switch error { - case .error(let err): - log.error(err) - self.displayLoadingResourceError() - case .expired: - if isUserInitiated || self.playerView.repeatState == .repeatOne || assetCount <= 1 { - let item = PlaylistManager.shared.itemAtIndex(index) - self.displayExpiredResourceError(item: item) - } else { - DispatchQueue.main.async { - self.currentlyPlayingItemIndex = index - self.onNextTrack(isUserInitiated: isUserInitiated) - } - } - case .none: - self.currentlyPlayingItemIndex = index - self.updateLastPlayedItem(indexPath: indexPath) - } - } - } - } - - func onPictureInPicture(enabled: Bool) { - playerView.pictureInPictureController?.delegate = enabled ? self : nil - } - - func onSidePanelStateChanged() { - (splitViewController?.parent as? PlaylistViewController)?.onSidePanelStateChanged() - } - - func onFullScreen() { - if !UIDevice.isIpad || splitViewController?.isCollapsed == true { - navigationController?.setNavigationBarHidden(true, animated: true) - tableView.isHidden = true - playerView.snp.remakeConstraints { - $0.edges.equalToSuperview() - } - } else { - (splitViewController?.parent as? PlaylistViewController)?.onFullscreen() - } - } - - func onExitFullScreen() { - if UIDevice.isIpad && splitViewController?.isCollapsed == false { - playerView.setFullscreenButtonHidden(true) - playerView.setExitButtonHidden(false) - splitViewController?.parent?.dismiss(animated: true, completion: nil) - } else if UIDevice.isIpad && splitViewController?.isCollapsed == true { - navigationController?.setNavigationBarHidden(false, animated: true) - playerView.setFullscreenButtonHidden(true) - updateLayoutForMode(.iPhoneLayout) - } else if UIDevice.current.orientation.isPortrait { - navigationController?.setNavigationBarHidden(false, animated: true) - tableView.isHidden = false - updateLayoutForMode(.iPhoneLayout) - } else { - playerView.setFullscreenButtonHidden(true) - playerView.setExitButtonHidden(false) - splitViewController?.parent?.dismiss(animated: true, completion: nil) - } - } -} - -// MARK: AVPlayerViewControllerDelegate && AVPictureInPictureControllerDelegate - -extension ListController: AVPlayerViewControllerDelegate, AVPictureInPictureControllerDelegate { - - // MARK: - AVPlayerViewControllerDelegate - - func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool { - true - } - - func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - - playerView.detachLayer() - playerController = playerViewController - } - - func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - - playerView.attachLayer() - playerController = nil - } - - func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { - playerView.detachLayer() - - (UIApplication.shared.delegate as? AppDelegate)?.playlistRestorationController = splitViewController?.parent - } - - func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) { - DispatchQueue.main.async { - self.playerView.detachLayer() - self.dismiss(animated: true, completion: nil) - } - } - - func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { - if let delegate = UIApplication.shared.delegate as? AppDelegate { - playerView.attachLayer() - delegate.playlistRestorationController = nil - playerController = nil - } - } - - func playerViewController(_ playerViewController: AVPlayerViewController, failedToStartPictureInPictureWithError error: Error) { - playerView.attachLayer() - - let alert = UIAlertController(title: Strings.PlayList.sorryAlertTitle, - message: Strings.PlayList.pictureInPictureErrorTitle, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: Strings.PlayList.okayButtonTitle, style: .default, handler: nil)) - self.present(alert, animated: true, completion: nil) - } - - func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { - - if let delegate = UIApplication.shared.delegate as? AppDelegate, - let restorationController = delegate.playlistRestorationController { - restorationController.modalPresentationStyle = .fullScreen - playerView.attachLayer() - if view.window == nil { - delegate.browserViewController.present(restorationController, animated: true) { - self.playerView.player.play() - delegate.playlistRestorationController = nil - } - } else { - self.playerView.player.play() - delegate.playlistRestorationController = nil - } - } - - playerController = nil - completionHandler(true) - } - - // MARK: - AVPictureInPictureControllerDelegate - func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { - - (UIApplication.shared.delegate as? AppDelegate)?.playlistRestorationController = splitViewController?.parent - - if UIDevice.isIpad { - splitViewController?.dismiss(animated: true, completion: nil) - } - } - - func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { - if UIDevice.isPhone { - DispatchQueue.main.async { - self.dismiss(animated: true, completion: nil) - } - } - } - - func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { - if let delegate = UIApplication.shared.delegate as? AppDelegate { - if UIDevice.isIpad { - playerView.attachLayer() - } - delegate.playlistRestorationController = nil - } - } - - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) { - - let alert = UIAlertController(title: Strings.PlayList.sorryAlertTitle, - message: Strings.PlayList.pictureInPictureErrorTitle, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: Strings.PlayList.okayButtonTitle, style: .default, handler: nil)) - self.present(alert, animated: true, completion: nil) - } - - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { - - if let delegate = UIApplication.shared.delegate as? AppDelegate, - let restorationController = delegate.playlistRestorationController { - restorationController.modalPresentationStyle = .fullScreen - if view.window == nil { - delegate.browserViewController.present(restorationController, animated: true) { - delegate.playlistRestorationController = nil - } - } else { - delegate.playlistRestorationController = nil - } - } - - completionHandler(true) - } -} - -extension ListController: PlaylistManagerDelegate { - func onDownloadProgressUpdate(id: String, percentComplete: Double) { - guard let index = PlaylistManager.shared.index(of: id) else { - return - } - - let indexPath = IndexPath(row: index, section: 0) - guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? PlaylistCell else { - return - } - - // Cell is not visible, do not update percentages - if tableView.indexPathsForVisibleRows?.contains(indexPath) == false { - return - } - - let cacheState = PlaylistManager.shared.state(for: id) - switch cacheState { - case .inProgress: - let item = PlaylistManager.shared.itemAtIndex(index) - cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in - cell?.detailLabel.text = "\($0) - \(Int(percentComplete))% \(Strings.PlayList.savedForOfflineLabelTitle)" - } - case .downloaded: - let item = PlaylistManager.shared.itemAtIndex(index) - if let itemSize = PlaylistManager.shared.sizeOfDownloadedItem(for: item.pageSrc) { - cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in - cell?.detailLabel.text = "\($0) - \(itemSize)" - } - } else { - cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in - cell?.detailLabel.text = "\($0) - \(Strings.PlayList.savedForOfflineLabelTitle)" - } - } - case .invalid: - let item = PlaylistManager.shared.itemAtIndex(index) - cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in - cell?.detailLabel.text = $0 - } - } - } - - func onDownloadStateChanged(id: String, state: PlaylistDownloadManager.DownloadState, displayName: String?, error: Error?) { - guard let index = PlaylistManager.shared.index(of: id) else { - return - } - - let indexPath = IndexPath(row: index, section: 0) - guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? PlaylistCell else { - return - } - - // Cell is not visible, do not update status - if tableView.indexPathsForVisibleRows?.contains(indexPath) == false { - return - } - - if let error = error { - log.error("Error downloading playlist item: \(error)") - - let item = PlaylistManager.shared.itemAtIndex(index) - cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in - cell?.detailLabel.text = $0 - } - - let alert = UIAlertController(title: Strings.PlayList.playlistSaveForOfflineErrorTitle, - message: Strings.PlayList.playlistSaveForOfflineErrorMessage, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: Strings.PlayList.okayButtonTitle, style: .default, handler: nil)) - self.present(alert, animated: true, completion: nil) - } else { - switch state { - case .inProgress: - let item = PlaylistManager.shared.itemAtIndex(index) - cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in - cell?.detailLabel.text = "\($0) - \(Strings.PlayList.savingForOfflineLabelTitle)" - } - case .downloaded: - let item = PlaylistManager.shared.itemAtIndex(index) - if let itemSize = PlaylistManager.shared.sizeOfDownloadedItem(for: item.pageSrc) { - cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in - cell?.detailLabel.text = "\($0) - \(itemSize)" - } - } else { - cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in - cell?.detailLabel.text = "\($0) - \(Strings.PlayList.savedForOfflineLabelTitle)" - } - } - case .invalid: - let item = PlaylistManager.shared.itemAtIndex(index) - cell.durationFetcher = getAssetDurationFormatted(item: item) { [weak cell] in - cell?.detailLabel.text = $0 - } - } - } - } - - func controllerDidChange(_ anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { - - if tableView.hasActiveDrag || tableView.hasActiveDrop { return } - - switch type { - case .insert: - guard let newIndexPath = newIndexPath else { break } - tableView.insertRows(at: [newIndexPath], with: .fade) - case .delete: - guard let indexPath = indexPath else { break } - tableView.deleteRows(at: [indexPath], with: .fade) - case .update: - guard let indexPath = indexPath else { break } - tableView.reloadRows(at: [indexPath], with: .fade) - case .move: - guard let indexPath = indexPath, - let newIndexPath = newIndexPath else { break } - tableView.deleteRows(at: [indexPath], with: .fade) - tableView.insertRows(at: [newIndexPath], with: .fade) - default: - break - } - } - - func controllerDidChangeContent() { - if tableView.hasActiveDrag || tableView.hasActiveDrop { return } - tableView.endUpdates() - } - - func controllerWillChangeContent() { - if tableView.hasActiveDrag || tableView.hasActiveDrop { return } - tableView.beginUpdates() - } -} - -extension ListController { - func updateTableBackgroundView() { - if PlaylistManager.shared.numberOfAssets > 0 { - tableView.backgroundView = nil - tableView.separatorStyle = .singleLine - } else { - let messageLabel = UILabel(frame: view.bounds).then { - $0.text = Strings.PlayList.noItemLabelTitle - $0.textColor = .white - $0.numberOfLines = 0 - $0.textAlignment = .center - $0.font = .systemFont(ofSize: 18.0, weight: .medium) - $0.sizeToFit() - } - - tableView.backgroundView = messageLabel - tableView.separatorStyle = .none - } - } -} - -private class DetailController: UIViewController, UIGestureRecognizerDelegate { - - private weak var playerView: VideoView? - - override func viewDidLoad() { - super.viewDidLoad() - - setup() - layoutBarButtons() - addGestureRecognizers() - } - - // MARK: Private - - private func setup() { - view.backgroundColor = .black - - navigationController?.do { - let appearance = UINavigationBarAppearance() - appearance.configureWithTransparentBackground() - appearance.titleTextAttributes = [.foregroundColor: UIColor.white] - appearance.backgroundColor = .braveBackground - - $0.navigationBar.standardAppearance = appearance - $0.navigationBar.barTintColor = UIColor.braveBackground - $0.navigationBar.tintColor = .white - } - } - - private func layoutBarButtons() { - let exitBarButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(onExit(_:))) - let sideListBarButton = UIBarButtonItem(image: #imageLiteral(resourceName: "playlist_split_navigation"), style: .done, target: self, action: #selector(onDisplayModeChange)) - - navigationItem.rightBarButtonItem = - PlayListSide(rawValue: Preferences.Playlist.listViewSide.value) == .left ? exitBarButton : sideListBarButton - navigationItem.leftBarButtonItem = - PlayListSide(rawValue: Preferences.Playlist.listViewSide.value) == .left ? sideListBarButton : exitBarButton - } - - private func addGestureRecognizers() { - let slideToRevealGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleGesture)) - slideToRevealGesture.direction = PlayListSide(rawValue: Preferences.Playlist.listViewSide.value) == .left ? .right : .left - - view.addGestureRecognizer(slideToRevealGesture) - } - - private func updateSplitViewDisplayMode(to displayMode: UISplitViewController.DisplayMode) { - UIView.animate(withDuration: 0.2) { - self.splitViewController?.preferredDisplayMode = displayMode - } - } - - // MARK: Actions - - func onSidePanelStateChanged() { - onDisplayModeChange() - } - - func onFullScreen() { - navigationController?.setNavigationBarHidden(true, animated: true) - - if navigationController?.isNavigationBarHidden == true { - splitViewController?.preferredDisplayMode = .secondaryOnly - } - } - - func onExitFullScreen() { - navigationController?.setNavigationBarHidden(false, animated: true) - - if navigationController?.isNavigationBarHidden == true { - splitViewController?.preferredDisplayMode = .primaryOverlay - } - } - - @objc - private func onExit(_ button: UIBarButtonItem) { - dismiss(animated: true, completion: nil) - } - - @objc - func handleGesture(gesture: UISwipeGestureRecognizer) { - guard gesture.direction == .right, - let playerView = playerView, - !playerView.checkInsideTrackBar(point: gesture.location(in: view)) else { - return - } - - onDisplayModeChange() - } - - @objc - private func onDisplayModeChange() { - updateSplitViewDisplayMode( - to: splitViewController?.displayMode == .primaryOverlay ? .secondaryOnly : .primaryOverlay) - } - - public func setVideoPlayer(_ videoPlayer: VideoView?) { - if playerView?.superview == view { - playerView?.removeFromSuperview() - } - - playerView = videoPlayer - } - - public func updateLayoutForMode(_ mode: DisplayMode) { - guard let playerView = playerView else { return } - - if mode == .iPadLayout { - view.addSubview(playerView) - playerView.snp.makeConstraints { - $0.bottom.leading.trailing.equalTo(view) - $0.top.equalTo(view.safeAreaLayoutGuide) - } - } else { - if playerView.superview == view { - playerView.removeFromSuperview() - } - } - } -} diff --git a/Client/Frontend/Browser/Playlist/VideoPlayer/DataURIParser.swift b/Client/Frontend/Browser/Playlist/Utilities/DataURIParser.swift similarity index 100% rename from Client/Frontend/Browser/Playlist/VideoPlayer/DataURIParser.swift rename to Client/Frontend/Browser/Playlist/Utilities/DataURIParser.swift diff --git a/Client/Frontend/Browser/Playlist/Utilities/PlaylistMediaStreamer.swift b/Client/Frontend/Browser/Playlist/Utilities/PlaylistMediaStreamer.swift new file mode 100644 index 00000000000..fd27d24f8ec --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Utilities/PlaylistMediaStreamer.swift @@ -0,0 +1,191 @@ +// 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 Combine +import Data +import WebKit +import MediaPlayer +import Shared + +private let log = Logger.browserLogger + +class PlaylistMediaStreamer { + private weak var playerView: UIView? + private var webLoader: PlaylistWebLoader? + + enum PlaybackError: Error { + case none + case cancelled + case expired + case other(Error) + } + + init(playerView: UIView) { + self.playerView = playerView + } + + func loadMediaStreamingAsset(_ item: PlaylistInfo) -> AnyPublisher { + // We need to check if the item is cached locally. + // If the item is cached (downloaded) + // then we can play it directly without having to stream it. + let cacheState = PlaylistManager.shared.state(for: item.pageSrc) + if cacheState != .invalid { + return Future { resolver in + resolver(.success(Void())) + }.eraseToAnyPublisher() + } + + // Determine if an item can be streamed and stream it directly + guard !item.src.isEmpty, let url = URL(string: item.src) else { + // Fallback to the webview because there was no stream URL somehow.. + return self.streamingFallback(item).eraseToAnyPublisher() + } + + // Try to stream the asset from its url.. + return canStreamURL(url).flatMap { canStream -> AnyPublisher in + // Stream failed so fallback to the webview + // It's possible the URL expired.. + if !canStream { + return self.streamingFallback(item).eraseToAnyPublisher() + } + + return Future { resolver in + resolver(.success(Void())) + }.eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + // MARK: - Private + + private func streamingFallback(_ item: PlaylistInfo) -> Future { + // Fallback to web stream + return Future { [weak self] resolver in + guard let self = self else { + resolver(.failure(.other("Streaming Cancelled"))) + return + } + + self.webLoader = PlaylistWebLoader(handler: { [weak self] newItem in + guard let self = self else { return } + defer { + // Destroy the web loader when the callback is complete. + self.webLoader?.removeFromSuperview() + self.webLoader = nil + } + + if let newItem = newItem, URL(string: newItem.src) != nil { + PlaylistItem.updateItem(newItem) { + resolver(.success(Void())) + } + } else { + resolver(.failure(.expired)) + } + }).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?.window?.insertSubview($0, at: 0) + } + + if let url = URL(string: item.pageSrc) { + self.webLoader?.load(url: url) + } else { + resolver(.failure(.other("Cannot Load Media"))) + } + } + } + + // Would be nice if AVPlayer could detect the mime-type from the URL for my delegate without a head request.. + // This function only exists because I can't figure out why videos from URLs don't play unless I explicitly specify a mime-type.. + private func canStreamURL(_ url: URL) -> Future { + return Future { resolver in + PlaylistMediaStreamer.getMimeType(url) { mimeType in + if let mimeType = mimeType { + resolver(.success(!mimeType.isEmpty)) + } else { + resolver(.success(false)) + } + } + } + } + + // MARK: - Static + + static func setNowPlayingInfo(_ item: PlaylistInfo, withPlayer player: MediaPlayer) { + let mediaType: MPNowPlayingInfoMediaType = + item.mimeType.contains("video") ? .video : .audio + + MPNowPlayingInfoCenter.default().nowPlayingInfo = [ + MPNowPlayingInfoPropertyMediaType: NSNumber(value: mediaType.rawValue), + MPMediaItemPropertyTitle: item.name, + MPMediaItemPropertyArtist: URL(string: item.pageSrc)?.baseDomain ?? item.pageSrc, + MPMediaItemPropertyPlaybackDuration: item.duration, + MPNowPlayingInfoPropertyPlaybackProgress: 0.0, +// MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0, + MPNowPlayingInfoPropertyAssetURL: URL(string: item.pageSrc) as Any, + MPNowPlayingInfoPropertyElapsedPlaybackTime: 0.0, //player.currentTime.seconds + //MPNowPlayingInfoPropertyPlaybackQueueIndex: 0, + //MPNowPlayingInfoPropertyPlaybackQueueCount: 0 + ] + } + + static func clearNowPlayingInfo() { + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + } + + static func setNowPlayingMediaArtwork(image: UIImage?) { + if let image = image { + let artwork = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ -> UIImage in + // Do not resize image here. + // According to Apple it isn't necessary to use expensive resize operations + return image + }) + setNowPlayingMediaArtwork(artwork: artwork) + } + } + + static func setNowPlayingMediaArtwork(artwork: MPMediaItemArtwork?) { + if let artwork = artwork { + MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyArtwork] = artwork + } + } + + static func getMimeType(_ url: URL, _ completion: @escaping (String?) -> Void) { + let request: URLRequest = { + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10.0) + + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range + request.addValue("bytes=0-1", forHTTPHeaderField: "Range") + request.addValue(UUID().uuidString, forHTTPHeaderField: "X-Playback-Session-Id") + request.addValue(UserAgent.shouldUseDesktopMode ? UserAgent.desktop : UserAgent.mobile, forHTTPHeaderField: "User-Agent") + return request + }() + + let session = URLSession(configuration: .ephemeral) + session.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { + log.error("Error fetching MimeType for playlist item: \(url) - \(error)") + return completion(nil) + } + + if let response = response as? HTTPURLResponse, response.statusCode == 302 || response.statusCode >= 200 && response.statusCode <= 299 { + if let contentType = response.allHeaderFields["Content-Type"] as? String { + completion(contentType) + return + } else { + completion("video/*") + return + } + } + + completion(nil) + } + }.resume() + session.finishTasksAndInvalidate() + } +} diff --git a/Client/Frontend/Browser/Playlist/Utilities/PlaylistStatusObserver.swift b/Client/Frontend/Browser/Playlist/Utilities/PlaylistStatusObserver.swift new file mode 100644 index 00000000000..a4923e9cbee --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Utilities/PlaylistStatusObserver.swift @@ -0,0 +1,53 @@ +// 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 +import MediaPlayer +import AVKit +import AVFoundation +import Shared +import Data + +private let log = Logger.browserLogger + +class PlaylistPlayerStatusObserver: NSObject { + private weak var player: AVPlayer? + private var item: AVPlayerItem? + private var onStatusChanged: (AVPlayerItem.Status) -> Void + private var currentItemObserver: NSKeyValueObservation? + private var itemStatusObserver: NSKeyValueObservation? + + init(player: AVPlayer, onStatusChanged: @escaping (AVPlayerItem.Status) -> Void) { + self.onStatusChanged = onStatusChanged + super.init() + + self.player = player + currentItemObserver = player.observe(\AVPlayer.currentItem, options: [.new], changeHandler: { [weak self] _, change in + guard let self = self else { return } + + if let newItem = change.newValue { + self.item = newItem + self.itemStatusObserver = newItem?.observe(\AVPlayerItem.status, options: [.new], changeHandler: { [weak self] _, change in + guard let self = self else { return } + + let status = change.newValue ?? .unknown + switch status { + case .readyToPlay: + log.debug("Player Item Status: Ready") + self.onStatusChanged(.readyToPlay) + case .failed: + log.debug("Player Item Status: Failed") + self.onStatusChanged(.failed) + case .unknown: + log.debug("Player Item Status: Unknown") + self.onStatusChanged(.unknown) + @unknown default: + assertionFailure("Unknown Switch Case for AVPlayerItemStatus") + } + }) + } + }) + } +} diff --git a/Client/Frontend/Browser/Playlist/Utilities/PlaylistThumbnailUtility.swift b/Client/Frontend/Browser/Playlist/Utilities/PlaylistThumbnailUtility.swift new file mode 100644 index 00000000000..f30d5c01a96 --- /dev/null +++ b/Client/Frontend/Browser/Playlist/Utilities/PlaylistThumbnailUtility.swift @@ -0,0 +1,291 @@ +// 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 CoreImage +import Combine +import SDWebImage +import Shared + +private let log = Logger.browserLogger + +public class PlaylistThumbnailRenderer { + private let timeout: TimeInterval = 3 + private var hlsGenerator: HLSThumbnailGenerator? + private var assetGenerator: AVAssetImageGenerator? + private var favIconGenerator: FavIconImageRenderer? + private var thumbnailGenerator = Set() + + func loadThumbnail(assetUrl: URL, favIconUrl: URL, completion: @escaping (UIImage?) -> Void) { + if let cachedImage = SDImageCache.shared.imageFromCache(forKey: assetUrl.absoluteString) { + self.destroy() + completion(cachedImage) + } else { + var generators = [bind(loadHLSThumbnail, url: assetUrl), + bind(loadAssetThumbnail, url: assetUrl), + bind(loadFavIconThumbnail, url: favIconUrl)] + + var chainedGenerator = generators.removeFirst().eraseToAnyPublisher() + for generator in generators { + chainedGenerator = chainedGenerator.catch { _ in + generator + }.eraseToAnyPublisher() + } + + chainedGenerator.receive(on: RunLoop.main).sink(receiveCompletion: { + if case .failure(let error) = $0 { + log.error(error) + completion(nil) + } + }, receiveValue: { + completion($0) + }).store(in: &thumbnailGenerator) + } + } + + func cancel() { + self.destroy() + } + + deinit { + destroy() + } + + private func destroy() { + thumbnailGenerator.forEach({ $0.cancel() }) + thumbnailGenerator = Set() + + hlsGenerator = nil + assetGenerator = nil + favIconGenerator = nil + } + + private func bind(_ block: @escaping (URL, @escaping (UIImage?) -> Void) -> Void, url: URL) -> Future { + Future { promise in + block(url, { image in + if let image = image { + promise(.success(image)) + } else { + promise(.failure("Image could not be loaded")) + } + }) + } + } + + private func loadHLSThumbnail(url: URL, completion: @escaping (UIImage?) -> Void) { + hlsGenerator = HLSThumbnailGenerator(url: url, time: timeout, completion: { image, error in + if let error = error { + log.error(error) + } + + if let image = image { + SDImageCache.shared.store(image, forKey: url.absoluteString, completion: nil) + } + + DispatchQueue.main.async { + completion(image) + } + }) + } + + private func loadAssetThumbnail(url: URL, completion: @escaping (UIImage?) -> Void) { + let time = CMTime(seconds: timeout, preferredTimescale: CMTimeScale(1)) + assetGenerator = AVAssetImageGenerator(asset: AVAsset(url: url)) + assetGenerator?.appliesPreferredTrackTransform = false + assetGenerator?.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { _, cgImage, _, result, error in + if let error = error { + log.error(error) + } + + if result == .succeeded, let cgImage = cgImage { + let image = UIImage(cgImage: cgImage) + SDImageCache.shared.store(image, forKey: url.absoluteString, completion: nil) + + DispatchQueue.main.async { + completion(image) + } + } else { + DispatchQueue.main.async { + completion(nil) + } + } + } + } + + private func loadFavIconThumbnail(url: URL, completion: @escaping (UIImage?) -> Void) { + favIconGenerator = FavIconImageRenderer() + favIconGenerator?.loadIcon(siteURL: url) { icon in + DispatchQueue.main.async { + completion(icon) + } + } + } +} + +// MARK: - HLSThumbnailGenerator + +/// A class for generating Thumbnails from HLS Streams +private class HLSThumbnailGenerator { + private enum State { + case loading + case ready + case failed + } + + private let asset: AVAsset + private let sourceURL: URL + private let player: AVPlayer? + private let videoOutput: AVPlayerItemVideoOutput? + private var observer: NSKeyValueObservation? + private var state: State = .loading + private let queue = DispatchQueue(label: "com.brave.hls-thumbnail-generator") + private let completion: (UIImage?, Error?) -> Void + + init(url: URL, time: TimeInterval, completion: @escaping (UIImage?, Error?) -> Void) { + self.asset = AVAsset(url: url) + self.sourceURL = url + self.completion = completion + + let item = AVPlayerItem(asset: asset, automaticallyLoadedAssetKeys: []) + self.player = AVPlayer(playerItem: item).then { + $0.rate = 0 + } + + self.videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA + ]) + + self.observer = self.player?.currentItem?.observe(\.status) { [weak self] item, _ in + guard let self = self else { return } + + if item.status == .readyToPlay && self.state == .loading { + self.state = .ready + self.generateThumbnail(at: time) + } else if item.status == .failed { + self.state = .failed + DispatchQueue.main.async { + self.completion(nil, "Failed to load item") + } + } + } + + if let videoOutput = self.videoOutput { + self.player?.currentItem?.add(videoOutput) + } + } + + private func generateThumbnail(at time: TimeInterval) { + queue.async { + let time = CMTime(seconds: time, preferredTimescale: 1) + self.player?.seek(to: time) { [weak self] finished in + guard let self = self else { return } + + if finished { + self.queue.async { + if let buffer = self.videoOutput?.copyPixelBuffer(forItemTime: time, itemTimeForDisplay: nil) { + self.snapshotPixelBuffer(buffer, atTime: time.seconds) + } else { + DispatchQueue.main.async { + self.completion(nil, "Cannot copy pixel-buffer (PBO)") + } + } + } + } else { + DispatchQueue.main.async { + self.completion(nil, "Failed to seek to specified time") + } + } + } + } + } + + private func snapshotPixelBuffer(_ buffer: CVPixelBuffer, atTime time: TimeInterval) { + let ciImage = CIImage(cvPixelBuffer: buffer) + let quartzFrame = CGRect(x: 0, y: 0, + width: CVPixelBufferGetWidth(buffer), + height: CVPixelBufferGetHeight(buffer)) + + if let cgImage = CIContext().createCGImage(ciImage, from: quartzFrame) { + let result = UIImage(cgImage: cgImage) + + DispatchQueue.main.async { + self.completion(result, nil) + } + } else { + DispatchQueue.main.async { + self.completion(nil, "Failed to create image from pixel-buffer frame.") + } + } + } + +} + +// MARK: - FavIconImageRenderer + +/// A class for rendering a FavIcon onto a `UIImage` +private class FavIconImageRenderer { + private var task: DispatchWorkItem? + + deinit { + task?.cancel() + } + + func loadIcon(siteURL: URL, completion: ((UIImage?) -> Void)?) { + task?.cancel() + task = DispatchWorkItem { + let faviconFetcher: FaviconFetcher? = FaviconFetcher(siteURL: siteURL, kind: .favicon, domain: nil) + faviconFetcher?.load() { [weak self] _, attributes in + guard let self = self, + let cancellable = self.task, + !cancellable.isCancelled else { + completion?(nil) + return + } + + if let image = attributes.image { + let finalImage = self.renderOnImageContext { context, rect in + if let backgroundColor = attributes.backgroundColor { + context.setFillColor(backgroundColor.cgColor) + } + + if let image = image.cgImage { + context.draw(image, in: rect) + } + } + + completion?(finalImage) + } else { + // Monogram favicon attributes + let label = UILabel().then { + $0.textColor = .white + $0.backgroundColor = .clear + $0.minimumScaleFactor = 0.5 + } + + label.text = FaviconFetcher.monogramLetter( + for: siteURL, + fallbackCharacter: nil + ) + + let finalImage = self.renderOnImageContext { context, _ in + label.layer.render(in: context) + } + + completion?(finalImage) + } + } + } + } + + private func renderOnImageContext(_ draw: (CGContext, CGRect) -> Void) -> UIImage? { + let size = CGSize(width: 100.0, height: 100.0) + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + draw(UIGraphicsGetCurrentContext()!, CGRect(size: size)) + let img = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return img + } +} diff --git a/Client/Frontend/Browser/Playlist/VideoPlayer/Extensions/MPRemoteCommandCenter+Combine.swift b/Client/Frontend/Browser/Playlist/VideoPlayer/Extensions/MPRemoteCommandCenter+Combine.swift new file mode 100644 index 00000000000..8bee3897b25 --- /dev/null +++ b/Client/Frontend/Browser/Playlist/VideoPlayer/Extensions/MPRemoteCommandCenter+Combine.swift @@ -0,0 +1,113 @@ +// 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 MediaPlayer +import Combine + +extension MPRemoteCommandCenter { + func publisher(for event: Command) -> EventPublisher { + EventPublisher(command: event.command) + } + + enum Command { + case pauseCommand + case playCommand + case stopCommand + case togglePlayPauseCommand + case enableLanguageOptionCommand + case disableLanguageOptionCommand + case changePlaybackRateCommand + case changeRepeatModeCommand + case changeShuffleModeCommand + case nextTrackCommand + case previousTrackCommand + case skipForwardCommand + case skipBackwardCommand + case seekForwardCommand + case seekBackwardCommand + case changePlaybackPositionCommand + case ratingCommand + case likeCommand + case dislikeCommand + case bookmarkCommand + + var command: MPRemoteCommand { + let center = MPRemoteCommandCenter.shared() + switch self { + case .pauseCommand: return center.pauseCommand + case .playCommand: return center.playCommand + case .stopCommand: return center.stopCommand + case .togglePlayPauseCommand: return center.togglePlayPauseCommand + case .enableLanguageOptionCommand: return center.enableLanguageOptionCommand + case .disableLanguageOptionCommand: return center.disableLanguageOptionCommand + case .changePlaybackRateCommand: return center.changePlaybackRateCommand + case .changeRepeatModeCommand: return center.changeRepeatModeCommand + case .changeShuffleModeCommand: return center.changeShuffleModeCommand + case .nextTrackCommand: return center.nextTrackCommand + case .previousTrackCommand: return center.previousTrackCommand + case .skipForwardCommand: return center.skipForwardCommand + case .skipBackwardCommand: return center.skipBackwardCommand + case .seekForwardCommand: return center.seekForwardCommand + case .seekBackwardCommand: return center.seekBackwardCommand + case .changePlaybackPositionCommand: return center.changePlaybackPositionCommand + case .ratingCommand: return center.ratingCommand + case .likeCommand: return center.likeCommand + case .dislikeCommand: return center.dislikeCommand + case .bookmarkCommand: return center.bookmarkCommand + } + } + } +} + +// A publisher and subscriber for MPRemoteCommand observers +extension MPRemoteCommandCenter { + struct EventPublisher: Publisher { + typealias Output = MPRemoteCommandEvent + typealias Failure = Never + + private var command: MPRemoteCommand + + init(command: MPRemoteCommand) { + self.command = command + } + + func receive( + subscriber: S + ) where S.Input == Output, S.Failure == Failure { + let subscription = EventSubscription() + subscription.target = subscriber + + subscriber.receive(subscription: subscription) + subscription.observe(command) + } + } + + private class EventSubscription: Subscription + where Target.Input == MPRemoteCommandEvent { + + var target: Target? + + private var command: MPRemoteCommand? + private var observer: Any? + + func request(_ demand: Subscribers.Demand) {} + + func cancel() { + command?.removeTarget(observer) + target = nil + } + + func observe(_ command: MPRemoteCommand) { + self.command = command + observer = command.addTarget(handler: eventHandler) + } + + private func eventHandler(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + _ = target?.receive(event) + return .success + } + } +} diff --git a/Client/Frontend/Browser/Playlist/VideoPlayer/MediaPlayer.swift b/Client/Frontend/Browser/Playlist/VideoPlayer/MediaPlayer.swift new file mode 100644 index 00000000000..50c01ebf860 --- /dev/null +++ b/Client/Frontend/Browser/Playlist/VideoPlayer/MediaPlayer.swift @@ -0,0 +1,557 @@ +// 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 AVKit +import Combine +import MediaPlayer +import Shared + +private let log = Logger.browserLogger + +class MediaPlayer: NSObject { + public enum RepeatMode: CaseIterable { + case none + case repeatOne + case repeatAll + } + + public enum ShuffleMode: CaseIterable { + case none + case items + case collection + } + + // MARK: - Public Variables + + private(set) public var isSeeking = false + private(set) public var seekInterval: TimeInterval = 15.0 + 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) var previousRate: Float = -1.0 + + public var isPlaying: Bool { + // It is better NOT to keep tracking of isPlaying OR rate > 0.0 + // Instead we should use the timeControlStatus because PIP and Background play + // via control-center will modify the timeControlStatus property + // This will keep our UI consistent with what is on the lock-screen. + // This will also allow us to properly determine play state in + // PlaylistMediaInfo -> init -> MPRemoteCommandCenter.shared().playCommand + player.timeControlStatus == .playing + } + + public var currentItem: AVPlayerItem? { + player.currentItem + } + + public var currentTime: CMTime { + player.currentTime() + } + + public var rate: Float { + player.rate + } + + public var isLiveMedia: Bool { + (player.currentItem ?? pendingMediaItem)?.asset.duration.isIndefinite == true + } + + public var isAttachedToDisplay: Bool { + playerLayer.superlayer != nil + } + + override init() { + super.init() + + playerLayer.player = self.player + + // Register for notifications + registerNotifications() + registerControlCenterNotifications() + registerPictureInPictureNotifications() + + // For now, disable shuffling + MPRemoteCommandCenter.Command.changeShuffleModeCommand.command.isEnabled = false + + // Start receiving remote commands + UIApplication.shared.beginReceivingRemoteControlEvents() + + // Enable our audio session + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation) + } catch { + log.error(error) + } + } + + deinit { + // Unregister for notifications + notificationObservers.removeAll() + + if let periodicTimeObserver = periodicTimeObserver { + player.removeTimeObserver(periodicTimeObserver) + } + + // Disable our audio session + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .default, options: []) + try AVAudioSession.sharedInstance().setActive(false) + } catch { + log.error(error) + } + + // Stop receiving remote commands + UIApplication.shared.endReceivingRemoteControlEvents() + } + + func load(url: URL) -> Combine.Deferred> { + load(asset: AVURLAsset(url: url)) + } + + /// On success, returns a publisher with a Boolean. + /// The boolean indicates if a NEW player item was loaded, OR if an existing item was loaded. + /// 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) -> Combine.Deferred> { + return Deferred { [weak self] in + guard let self = self else { + return Fail(error: "MediaPlayer Deallocated") + .eraseToAnyPublisher() + } + + return Future { resolver in + // If the same asset is being loaded again. + // Just play it. + if let currentItem = self.player.currentItem, currentItem.asset.isKind(of: AVURLAsset.self) && self.player.status == .readyToPlay { + if let currentAsset = currentItem.asset as? AVURLAsset, currentAsset.url.absoluteString == asset.url.absoluteString { + resolver(.success(false)) // Same item is playing. + self.pendingMediaItem = nil + return + } + } + + let assetKeys = ["playable", "tracks", "duration"] + self.pendingMediaItem = AVPlayerItem(asset: asset) + asset.loadValuesAsynchronously(forKeys: assetKeys) { [weak self] in + guard let self = self, let item = self.pendingMediaItem else { return } + + for key in assetKeys { + var error: NSError? + let status = item.asset.statusOfValue(forKey: key, error: &error) + if let error = error { + resolver(.failure(error)) + return + } else if status != .loaded { + resolver(.failure("Cannot Load Asset Status: \(status)")) + return + } + } + + DispatchQueue.main.async { + self.player.replaceCurrentItem(with: item) + self.pendingMediaItem = nil + resolver(.success(true)) // New Item loaded + } + } + }.eraseToAnyPublisher() + } + } + + func play() { + if !isPlaying { + player.play() + player.rate = previousRate < 0.0 ? 1.0 : previousRate + playSubscriber.send(EventNotification(mediaPlayer: self, event: .play)) + } + } + + func pause() { + if isPlaying { + previousRate = player.rate + player.pause() + pauseSubscriber.send(EventNotification(mediaPlayer: self, event: .pause)) + } + } + + func stop() { + if isPlaying { + previousRate = 0.0 + player.pause() + player.replaceCurrentItem(with: nil) + stopSubscriber.send(EventNotification(mediaPlayer: self, event: .stop)) + } + } + + func seekPreviousTrack() { + previousTrackSubscriber.send(EventNotification(mediaPlayer: self, event: .previousTrack)) + } + + func seekNextTrack() { + nextTrackSubscriber.send(EventNotification(mediaPlayer: self, event: .nextTrack)) + } + + func seekBackwards() { + if let currentItem = player.currentItem { + let currentTime = currentItem.currentTime().seconds + var seekTime = currentTime - seekInterval + + if seekTime < 0 { + seekTime = 0 + } + + let absoluteTime = CMTimeMakeWithSeconds(seekTime, preferredTimescale: currentItem.currentTime().timescale) + player.seek(to: absoluteTime, toleranceBefore: .zero, toleranceAfter: .zero) + + seekBackwardSubscriber.send(EventNotification(mediaPlayer: self, event: .seekBackward)) + } + } + + func seekForwards() { + if let currentItem = player.currentItem { + let currentTime = currentItem.currentTime().seconds + let seekTime = currentTime + seekInterval + + if seekTime < (currentItem.duration.seconds - seekInterval) { + let absoluteTime = CMTimeMakeWithSeconds(seekTime, preferredTimescale: currentItem.currentTime().timescale) + player.seek(to: absoluteTime, toleranceBefore: .zero, toleranceAfter: .zero) + + seekForwardSubscriber.send(EventNotification(mediaPlayer: self, event: .seekForward)) + } + } + } + + func seek(to time: TimeInterval) { + if let currentItem = player.currentItem { + var seekTime = time + if seekTime < 0.0 { + seekTime = 0.0 + } + + if seekTime >= currentItem.duration.seconds { + seekTime = currentItem.duration.seconds + } + + let absoluteTime = CMTimeMakeWithSeconds(seekTime, preferredTimescale: currentItem.currentTime().timescale) + player.seek(to: absoluteTime, toleranceBefore: .zero, toleranceAfter: .zero) + + self.changePlaybackPositionSubscriber.send(EventNotification(mediaPlayer: self, + event: .changePlaybackPosition)) + } + } + + func toggleRepeatMode() { + let command = MPRemoteCommandCenter.shared().changeRepeatModeCommand + switch repeatState { + case .none: + command.currentRepeatType = .off + self.repeatState = .repeatOne + case .repeatOne: + command.currentRepeatType = .one + self.repeatState = .repeatAll + case .repeatAll: + command.currentRepeatType = .all + self.repeatState = .none + } + + changeRepeatModeSubscriber.send(EventNotification(mediaPlayer: self, + event: .changeRepeatMode)) + } + + func toggleShuffleMode() { + let command = MPRemoteCommandCenter.shared().changeShuffleModeCommand + switch shuffleState { + case .none: + command.currentShuffleType = .items + case .items: + command.currentShuffleType = .collections + case .collection: + command.currentShuffleType = .off + } + + changeShuffleModeSubscriber.send(EventNotification(mediaPlayer: self, + event: .changeShuffleMode)) + } + + func toggleGravity() { + switch playerLayer.videoGravity { + case .resize: + playerLayer.videoGravity = .resizeAspect + case .resizeAspect: + playerLayer.videoGravity = .resizeAspectFill + case .resizeAspectFill: + playerLayer.videoGravity = .resizeAspect + default: + assertionFailure("Invalid VideoPlayer Gravity") + } + + playerGravitySubscriber.send(EventNotification(mediaPlayer: self, + event: .playerGravityChanged)) + } + + func setPlaybackRate(rate: Float) { + previousRate = player.rate + player.rate = rate + changePlaybackRateSubscriber.send(EventNotification(mediaPlayer: self, + event: .changePlaybackRate)) + } + + func attachLayer() -> CALayer { + playerLayer.player = player + return playerLayer + } + + func detachLayer() { + playerLayer.player = nil + } + + // MARK: - Private Variables + + private let player = AVPlayer().then { + $0.seek(to: .zero) + $0.actionAtItemEnd = .none + } + + private let playerLayer = AVPlayerLayer().then { + $0.videoGravity = .resizeAspect + $0.needsDisplayOnBoundsChange = true + } + + private var periodicTimeObserver: Any? + private var notificationObservers = Set() + private let pauseSubscriber = PassthroughSubject() + private let playSubscriber = PassthroughSubject() + private let stopSubscriber = PassthroughSubject() + private let changePlaybackRateSubscriber = PassthroughSubject() + private let changeRepeatModeSubscriber = PassthroughSubject() + private let changeShuffleModeSubscriber = PassthroughSubject() + private let nextTrackSubscriber = PassthroughSubject() + private let previousTrackSubscriber = PassthroughSubject() + private let skipForwardSubscriber = PassthroughSubject() + private let skipBackwardSubscriber = PassthroughSubject() + private let seekForwardSubscriber = PassthroughSubject() + private let seekBackwardSubscriber = PassthroughSubject() + private let changePlaybackPositionSubscriber = PassthroughSubject() + private let finishedPlayingSubscriber = PassthroughSubject() + private let periodicTimeSubscriber = PassthroughSubject() + private let pictureInPictureStatusSubscriber = PassthroughSubject() + private let playerGravitySubscriber = PassthroughSubject() +} + +extension MediaPlayer { + enum Event { + case pause + case play + case stop + case changePlaybackRate + case changeRepeatMode + case changeShuffleMode + case nextTrack + case previousTrack + case skipForward + case skipBackward + case seekForward + case seekBackward + case changePlaybackPosition + case finishedPlaying + case periodicPlayTimeChanged + case pictureInPictureStatusChanged + case playerGravityChanged + } + + struct EventNotification { + let mediaPlayer: MediaPlayer + let event: Event + } + + public func publisher(for event: Event) -> AnyPublisher { + switch event { + case .pause: + return pauseSubscriber.eraseToAnyPublisher() + case .play: + return playSubscriber.eraseToAnyPublisher() + case .stop: + return stopSubscriber.eraseToAnyPublisher() + case .changePlaybackRate: + return changePlaybackRateSubscriber.eraseToAnyPublisher() + case .changeRepeatMode: + return changeRepeatModeSubscriber.eraseToAnyPublisher() + case .changeShuffleMode: + return changeShuffleModeSubscriber.eraseToAnyPublisher() + case .nextTrack: + return nextTrackSubscriber.eraseToAnyPublisher() + case .previousTrack: + return previousTrackSubscriber.eraseToAnyPublisher() + case .skipForward: + return skipForwardSubscriber.eraseToAnyPublisher() + case .skipBackward: + return skipBackwardSubscriber.eraseToAnyPublisher() + case .seekForward: + return seekForwardSubscriber.eraseToAnyPublisher() + case .seekBackward: + return seekBackwardSubscriber.eraseToAnyPublisher() + case .changePlaybackPosition: + return changePlaybackPositionSubscriber.eraseToAnyPublisher() + case .finishedPlaying: + return finishedPlayingSubscriber.eraseToAnyPublisher() + case .periodicPlayTimeChanged: + return periodicTimeSubscriber.eraseToAnyPublisher() + case .pictureInPictureStatusChanged: + return pictureInPictureStatusSubscriber.eraseToAnyPublisher() + case .playerGravityChanged: + return playerGravitySubscriber.eraseToAnyPublisher() + } + } +} + +extension MediaPlayer { + // Registers basic notifications + private func registerNotifications() { + NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + + if let pictureInPictureController = self.pictureInPictureController, + pictureInPictureController.isPictureInPictureActive { + return + } + + self.playerLayer.player = nil + }.store(in: ¬ificationObservers) + + NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + + if let pictureInPictureController = self.pictureInPictureController, + pictureInPictureController.isPictureInPictureActive { + return + } + + self.playerLayer.player = self.player + }.store(in: ¬ificationObservers) + + NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + + self.finishedPlayingSubscriber.send(EventNotification(mediaPlayer: self, + event: .finishedPlaying)) + }.store(in: ¬ificationObservers) + + let interval = CMTimeMake(value: 25, timescale: 1000) + periodicTimeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main, using: { [weak self] time in + guard let self = self else { return } + + self.periodicTimeSubscriber.send(EventNotification(mediaPlayer: self, + event: .periodicPlayTimeChanged)) + }) + } + + // Registers playback controls notifications + private func registerControlCenterNotifications() { + let center = MPRemoteCommandCenter.shared() + center.publisher(for: .pauseCommand).sink { [weak self] _ in + self?.pause() + }.store(in: ¬ificationObservers) + + center.publisher(for: .playCommand).sink { [weak self] _ in + self?.play() + }.store(in: ¬ificationObservers) + + center.publisher(for: .stopCommand).sink { [weak self] _ in + self?.stop() + }.store(in: ¬ificationObservers) + + center.changePlaybackRateCommand.supportedPlaybackRates = + supportedPlaybackRates.map { NSNumber(value: $0) } + center.publisher(for: .changePlaybackRateCommand).sink { [weak self] event in + guard let self = self, let event = event as? MPChangePlaybackRateCommandEvent else { return } + self.setPlaybackRate(rate: event.playbackRate) + }.store(in: ¬ificationObservers) + + center.publisher(for: .changeRepeatModeCommand).sink { [weak self] _ in + self?.toggleRepeatMode() + }.store(in: ¬ificationObservers) + + center.publisher(for: .changeShuffleModeCommand).sink { [weak self] _ in + self?.toggleShuffleMode() + }.store(in: ¬ificationObservers) + + center.publisher(for: .previousTrackCommand).sink { [weak self] _ in + self?.seekPreviousTrack() + }.store(in: ¬ificationObservers) + + center.publisher(for: .nextTrackCommand).sink { [weak self] _ in + self?.seekNextTrack() + }.store(in: ¬ificationObservers) + + center.skipBackwardCommand.preferredIntervals = [NSNumber(value: seekInterval)] + center.publisher(for: .skipBackwardCommand).sink { [weak self] event in + guard let self = self, + let event = event as? MPSkipIntervalCommandEvent else { return } + + let currentTime = self.player.currentTime() + self.seekBackwards() + MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = Double(currentTime.seconds - event.interval) + }.store(in: ¬ificationObservers) + + center.skipForwardCommand.preferredIntervals = [NSNumber(value: seekInterval)] + center.publisher(for: .skipForwardCommand).sink { [weak self] event in + guard let self = self, + let event = event as? MPSkipIntervalCommandEvent else { return } + + let currentTime = self.player.currentTime() + self.seekForwards() + MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = Double(currentTime.seconds + event.interval) + }.store(in: ¬ificationObservers) + + center.publisher(for: .changePlaybackPositionCommand).sink { [weak self] event in + guard let self = self, + let event = event as? MPChangePlaybackPositionCommandEvent else { return } + + self.seek(to: event.positionTime) + }.store(in: ¬ificationObservers) + } + + // Registers picture in picture notifications + private func registerPictureInPictureNotifications() { + if AVPictureInPictureController.isPictureInPictureSupported() { + pictureInPictureController = AVPictureInPictureController(playerLayer: self.playerLayer) + guard let pictureInPictureController = pictureInPictureController else { return } + + pictureInPictureController.publisher(for: \AVPictureInPictureController.isPictureInPicturePossible).sink { [weak self] status in + guard let self = self else { return } + self.pictureInPictureStatusSubscriber.send(EventNotification(mediaPlayer: self, + event: .pictureInPictureStatusChanged)) + }.store(in: ¬ificationObservers) + } else { + pictureInPictureStatusSubscriber.send(EventNotification(mediaPlayer: self, + event: .pictureInPictureStatusChanged)) + } + } +} + +extension AVAsset { + func isAudioTracksAvailable() -> Bool { + tracks.filter({ $0.mediaType == .audio }).isEmpty == false + } + + // If called on optional, assume true + // 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 { + tracks.isEmpty || tracks.filter({ $0.mediaType == .video }).isEmpty == false + } +} diff --git a/Client/Frontend/Browser/Playlist/VideoPlayer/PlaylistParticleEmitter.swift b/Client/Frontend/Browser/Playlist/VideoPlayer/UI/PlaylistParticleEmitter.swift similarity index 100% rename from Client/Frontend/Browser/Playlist/VideoPlayer/PlaylistParticleEmitter.swift rename to Client/Frontend/Browser/Playlist/VideoPlayer/UI/PlaylistParticleEmitter.swift diff --git a/Client/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayer.swift b/Client/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayer.swift new file mode 100644 index 00000000000..ecdcddb115e --- /dev/null +++ b/Client/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayer.swift @@ -0,0 +1,507 @@ +// 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 +import UIKit +import BraveShared +import Shared +import AVKit +import AVFoundation + +private let log = Logger.browserLogger + +protocol VideoViewDelegate: AnyObject { + func onPreviousTrack(_ videoView: VideoView, isUserInitiated: Bool) + func onNextTrack(_ videoView: VideoView, isUserInitiated: Bool) + func onSidePanelStateChanged(_ videoView: VideoView) + func onPictureInPicture(_ videoView: VideoView) + func onFullscreen(_ videoView: VideoView) + func onExitFullscreen(_ videoView: VideoView) + + func play(_ videoView: VideoView) + func pause(_ videoView: VideoView) + func stop(_ videoView: VideoView) + func seekBackwards(_ videoView: VideoView) + func seekForwards(_ videoView: VideoView) + func seek(_ videoView: VideoView, to time: TimeInterval) + func seek(_ videoView: VideoView, relativeOffset: Float) + func setPlaybackRate(_ videoView: VideoView, rate: Float) + func togglePlayerGravity(_ videoView: VideoView) + func toggleRepeatMode(_ videoView: VideoView) + + var isPlaying: Bool { get } + var repeatMode: MediaPlayer.RepeatMode { get } + var playbackRate: Float { get } + var isVideoTracksAvailable: Bool { get } +} + +class VideoView: UIView, VideoTrackerBarDelegate { + + weak var delegate: VideoViewDelegate? { + didSet { + self.toggleOverlays(showOverlay: true) + } + } + + private let particleView = PlaylistParticleEmitter().then { + $0.isHidden = false + $0.contentMode = .scaleAspectFit + $0.clipsToBounds = true + } + + private let overlayView = UIImageView().then { + $0.contentMode = .scaleAspectFit + $0.isUserInteractionEnabled = true + $0.backgroundColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.4024561216) + } + + let infoView = VideoPlayerInfoBar().then { + $0.layer.cornerRadius = 18.0 + $0.layer.masksToBounds = true + } + + let controlsView = VideoPlayerControlsView().then { + $0.layer.cornerRadius = 18.0 + $0.layer.masksToBounds = true + } + + // State + var isOverlayDisplayed = false + + private var isSeeking = false + private(set) var isFullscreen = false + private var wasPlayingBeforeSeeking = false + private var playbackRate: Float = 1.0 + private var playerLayer: AVPlayerLayer? + private var fadeAnimationWorkItem: DispatchWorkItem? + + override init(frame: CGRect) { + super.init(frame: frame) + + // Setup + backgroundColor = .black + + // Controls + infoView.sidePanelButton.addTarget(self, action: #selector(onSidePanel(_:)), for: .touchUpInside) + infoView.pictureInPictureButton.addTarget(self, action: #selector(onPictureInPicture(_:)), for: .touchUpInside) + infoView.fullscreenButton.addTarget(self, action: #selector(onFullscreen(_:)), for: .touchUpInside) + infoView.exitButton.addTarget(self, action: #selector(onExitFullscreen(_:)), for: .touchUpInside) + + controlsView.repeatButton.addTarget(self, action: #selector(onRepeat(_:)), for: .touchUpInside) + controlsView.playPauseButton.addTarget(self, action: #selector(onPlay(_:)), for: .touchUpInside) + controlsView.playbackRateButton.addTarget(self, action: #selector(onPlaybackRateChanged(_:)), for: .touchUpInside) + controlsView.skipBackButton.addTarget(self, action: #selector(onSeekBackwards(_:)), for: .touchUpInside) + controlsView.skipForwardButton.addTarget(self, action: #selector(onSeekForwards(_:)), for: .touchUpInside) + controlsView.skipBackButton.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(onSeekPrevious(_:)))) + controlsView.skipForwardButton.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(onSeekNext(_:)))) + controlsView.nextButton.addTarget(self, action: #selector(onNextTrack(_:)), for: .touchUpInside) + + // Layout + addSubview(particleView) + addSubview(overlayView) + addSubview(infoView) + addSubview(controlsView) + + particleView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + overlayView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + infoView.snp.makeConstraints { + $0.leading.equalTo(self.safeArea.leading).inset(8.0) + $0.trailing.equalTo(self.safeArea.trailing).inset(8.0) + $0.top.equalTo(self.safeArea.top).inset(8.0) + } + + controlsView.snp.makeConstraints { + $0.leading.equalTo(self.safeArea.leading).inset(8.0) + $0.trailing.equalTo(self.safeArea.trailing).inset(8.0) + $0.bottom.equalTo(self.safeArea.bottom).inset(8.0) + $0.height.equalTo(100.0) + } + + // Delegates + controlsView.trackBar.delegate = self + + // Gestures + let overlayTappedGesture = UITapGestureRecognizer(target: self, action: #selector(onOverlayTapped(_:))).then { + $0.numberOfTapsRequired = 1 + $0.numberOfTouchesRequired = 1 + } + + let overlayDoubleTappedGesture = UITapGestureRecognizer(target: self, action: #selector(onOverlayDoubleTapped(_:))).then { + $0.numberOfTapsRequired = 2 + $0.numberOfTouchesRequired = 1 + $0.delegate = self + } + + addGestureRecognizer(overlayTappedGesture) + addGestureRecognizer(overlayDoubleTappedGesture) + overlayTappedGesture.require(toFail: overlayDoubleTappedGesture) + + // Logic + self.toggleOverlays(showOverlay: true) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + detachLayer() + } + + override func layoutSubviews() { + super.layoutSubviews() + + playerLayer?.frame = self.bounds + } + + @objc + private func onOverlayTapped(_ gestureRecognizer: UITapGestureRecognizer) { + let isPlaying = delegate?.isPlaying == true + + if isSeeking { + toggleOverlays(showOverlay: true, except: [overlayView, infoView, controlsView.playPauseButton], display: [controlsView.trackBar]) + } else if (isPlaying && !isOverlayDisplayed) || (!isPlaying && !isSeeking && !isOverlayDisplayed) { + toggleOverlays(showOverlay: true) + isOverlayDisplayed = true + + fadeAnimationWorkItem?.cancel() + fadeAnimationWorkItem = DispatchWorkItem(block: { [weak self] in + guard let self = self else { return } + self.isOverlayDisplayed = false + + if self.delegate?.isPlaying == true && !self.isSeeking { + self.toggleOverlays(showOverlay: false) + } + }) + + guard let fadeAnimationWorkItem = fadeAnimationWorkItem else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: fadeAnimationWorkItem) + } else if isPlaying && isOverlayDisplayed { + toggleOverlays(showOverlay: false) + isOverlayDisplayed = false + } else { + toggleOverlays(showOverlay: true) + isOverlayDisplayed = true + } + } + + @objc + private func onOverlayDoubleTapped(_ gestureRecognizer: UITapGestureRecognizer) { + delegate?.togglePlayerGravity(self) + } + + private func seekDirectionWithAnimation(_ seekBlock: () -> Void) { + isSeeking = true + toggleOverlays(showOverlay: true) + isOverlayDisplayed = true + + seekBlock() + + fadeAnimationWorkItem?.cancel() + fadeAnimationWorkItem = DispatchWorkItem(block: { [weak self] in + guard let self = self else { return } + self.isSeeking = false + self.toggleOverlays(showOverlay: false) + self.isOverlayDisplayed = false + }) + + guard let fadeAnimationWorkItem = fadeAnimationWorkItem else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: fadeAnimationWorkItem) + } + + @objc + private func onRepeat(_ button: UIButton) { + guard let delegate = delegate else { return } + delegate.toggleRepeatMode(self) + } + + @objc + private func onPlay(_ button: UIButton) { + fadeAnimationWorkItem?.cancel() + guard let delegate = delegate else { return } + + if delegate.isPlaying { + self.pause() + } else { + self.play() + } + } + + @objc + private func onPlaybackRateChanged(_ button: UIButton) { + guard let delegate = delegate else { return } + + var playbackRate = delegate.playbackRate + if playbackRate == 1.0 { + playbackRate = 1.5 + button.setTitle("1.5x", for: .normal) + } else if playbackRate == 1.5 { + playbackRate = 2.0 + button.setTitle("2x", for: .normal) + } else { + playbackRate = 1.0 + button.setTitle("1x", for: .normal) + } + + let wasPlaying = delegate.isPlaying + delegate.setPlaybackRate(self, rate: playbackRate) + + if wasPlaying { + delegate.play(self) + controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_pause"), for: .normal) + } else { + delegate.pause(self) + controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_play"), for: .normal) + } + } + + @objc + private func onSidePanel(_ button: UIButton) { + self.delegate?.onSidePanelStateChanged(self) + } + + @objc + private func onPictureInPicture(_ button: UIButton) { + delegate?.onPictureInPicture(self) + } + + @objc + private func onFullscreen(_ button: UIButton) { + isFullscreen = true + infoView.fullscreenButton.isHidden = true + infoView.exitButton.isHidden = false + self.delegate?.onFullscreen(self) + } + + @objc + private func onExitFullscreen(_ button: UIButton) { + isFullscreen = false + infoView.fullscreenButton.isHidden = false + infoView.exitButton.isHidden = true + self.delegate?.onExitFullscreen(self) + } + + @objc + private func onSeekBackwards(_ button: UIButton) { + seekDirectionWithAnimation { + self.seekBackwards() + } + } + + @objc + private func onSeekForwards(_ button: UIButton) { + seekDirectionWithAnimation { + self.seekForwards() + } + } + + @objc + private func onSeekPrevious(_ gestureRecognizer: UIGestureRecognizer) { + if gestureRecognizer.state == .began { + self.delegate?.onPreviousTrack(self, isUserInitiated: true) + } + } + + @objc + private func onSeekNext(_ gestureRecognizer: UIGestureRecognizer) { + if gestureRecognizer.state == .began { + self.delegate?.onNextTrack(self, isUserInitiated: true) + } + } + + @objc + private func onNextTrack(_ button: UIButton) { + self.delegate?.onNextTrack(self, isUserInitiated: true) + } + + func onValueChanged(_ trackBar: VideoTrackerBar, value: CGFloat) { + guard let delegate = delegate else { return } + + isSeeking = true + + if delegate.isPlaying { + delegate.pause(self) + wasPlayingBeforeSeeking = true + playbackRate = delegate.playbackRate + } + + toggleOverlays(showOverlay: false, except: [infoView, controlsView], display: [controlsView]) + isOverlayDisplayed = true + + delegate.seek(self, relativeOffset: Float(value)) + } + + func onValueEnded(_ trackBar: VideoTrackerBar, value: CGFloat) { + guard let delegate = delegate else { return } + + isSeeking = false + + if wasPlayingBeforeSeeking { + delegate.play(self) + delegate.setPlaybackRate(self, rate: playbackRate) + wasPlayingBeforeSeeking = false + } + + if delegate.isPlaying { + fadeAnimationWorkItem?.cancel() + toggleOverlays(showOverlay: false, except: [overlayView], display: [overlayView]) + overlayView.alpha = 0.0 + isOverlayDisplayed = false + } else { + fadeAnimationWorkItem?.cancel() + toggleOverlays(showOverlay: true) + overlayView.alpha = 1.0 + isOverlayDisplayed = true + } + } + + func toggleOverlays(showOverlay: Bool) { + self.toggleOverlays(showOverlay: showOverlay, except: [], display: []) + } + + private func toggleOverlays(showOverlay: Bool, except: [UIView] = [], display: [UIView] = []) { + var except = except + var display = display + + if delegate?.isVideoTracksAvailable == true { + if showOverlay { + except.append(particleView) + } + } else { + // If the overlay is showing, hide the particle view.. else show it.. + except.append(particleView) + display.append(particleView) + } + + UIView.animate(withDuration: 1.0, delay: 0.0, usingSpringWithDamping: 1.0, initialSpringVelocity: 1.0, options: [.curveEaseInOut, .allowUserInteraction], animations: { + self.subviews.forEach({ + if !except.contains($0) { + $0.alpha = showOverlay ? 1.0 : 0.0 + } else if display.contains($0) { + $0.alpha = 1.0 + } else { + $0.alpha = 0.0 + } + }) + }) + } + + func setVideoInfo(videoDomain: String, videoTitle: String?) { + var displayTitle = videoTitle ?? "" + + if displayTitle.isEmpty { + var hostDomain = "" + + if let host = URL(string: videoDomain)?.baseDomain { + hostDomain = host + } + + if hostDomain.hasSuffix("/") { + hostDomain = String(hostDomain.dropLast()) + } + + if hostDomain.isEmpty { + hostDomain = videoDomain + } + + displayTitle = hostDomain + } + + infoView.titleLabel.text = displayTitle + infoView.updateFavIcon(domain: videoDomain) + } + + func resetVideoInfo() { + infoView.titleLabel.text = "" + infoView.clearFavIcon() + } + + func setControlsEnabled(_ enabled: Bool) { + // Disable all controls except the side-panel and the exit button + + infoView.fullscreenButton.isUserInteractionEnabled = enabled + infoView.pictureInPictureButton.isUserInteractionEnabled = enabled + controlsView.isUserInteractionEnabled = enabled + + gestureRecognizers?.forEach({ + $0.isEnabled = enabled + }) + } + + func setFullscreenButtonHidden(_ hidden: Bool) { + infoView.fullscreenButton.isHidden = hidden + } + + func setExitButtonHidden(_ hidden: Bool) { + infoView.exitButton.isHidden = hidden + } + + func setSidePanelHidden(_ hidden: Bool) { + infoView.sidePanelButton.isHidden = hidden + } + + func attachLayer(player: MediaPlayer) { + playerLayer = player.attachLayer() as? AVPlayerLayer + if let playerLayer = playerLayer { + layer.insertSublayer(playerLayer, at: 0) + } + } + + func detachLayer() { + playerLayer?.removeFromSuperlayer() + playerLayer?.player = nil + } + + func play() { + delegate?.play(self) + } + + func pause() { + delegate?.pause(self) + } + + func stop() { + delegate?.stop(self) + } + + func seek(to time: Double) { + delegate?.seek(self, to: time) + } + + func seekBackwards() { + delegate?.seekBackwards(self) + } + + func seekForwards() { + delegate?.seekForwards(self) + } + + func previous() { + self.delegate?.onPreviousTrack(self, isUserInitiated: false) + } + + func next() { + self.delegate?.onNextTrack(self, isUserInitiated: false) + } +} + +extension VideoView: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + let location = touch.location(in: self) + let restrictedViews = [infoView, controlsView] + for view in restrictedViews { + if view.point(inside: self.convert(location, to: view), with: nil) { + return false + } + } + return true + } +} diff --git a/Client/Frontend/Browser/Playlist/VideoPlayer/VideoPlayerControlsView.swift b/Client/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayerControlsView.swift similarity index 100% rename from Client/Frontend/Browser/Playlist/VideoPlayer/VideoPlayerControlsView.swift rename to Client/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayerControlsView.swift diff --git a/Client/Frontend/Browser/Playlist/VideoPlayer/VideoPlayerInfoBar.swift b/Client/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayerInfoBar.swift similarity index 100% rename from Client/Frontend/Browser/Playlist/VideoPlayer/VideoPlayerInfoBar.swift rename to Client/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayerInfoBar.swift diff --git a/Client/Frontend/Browser/Playlist/VideoPlayer/VideoPlayerTrackbar.swift b/Client/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayerTrackbar.swift similarity index 100% rename from Client/Frontend/Browser/Playlist/VideoPlayer/VideoPlayerTrackbar.swift rename to Client/Frontend/Browser/Playlist/VideoPlayer/UI/VideoPlayerTrackbar.swift diff --git a/Client/Frontend/Browser/Playlist/VideoPlayer/VideoPlayer.swift b/Client/Frontend/Browser/Playlist/VideoPlayer/VideoPlayer.swift deleted file mode 100644 index 7b037b082bb..00000000000 --- a/Client/Frontend/Browser/Playlist/VideoPlayer/VideoPlayer.swift +++ /dev/null @@ -1,786 +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 -import UIKit -import BraveShared -import Shared -import AVKit -import AVFoundation - -private let log = Logger.browserLogger - -public enum VideoViewRepeatMode { - case none - case repeatOne - case repeatAll -} - -public protocol VideoViewDelegate: AnyObject { - func onPreviousTrack(isUserInitiated: Bool) - func onNextTrack(isUserInitiated: Bool) - func onSidePanelStateChanged() - func onPictureInPicture(enabled: Bool) - func onFullScreen() - func onExitFullScreen() -} - -public class VideoView: UIView, VideoTrackerBarDelegate { - - weak var delegate: VideoViewDelegate? - - public let player = AVPlayer(playerItem: nil).then { - $0.seek(to: .zero) - $0.actionAtItemEnd = .none - } - - public let playerLayer = AVPlayerLayer().then { - $0.videoGravity = .resizeAspect - $0.needsDisplayOnBoundsChange = true - } - - private(set) public var pendingMediaItem: AVPlayerItem? - - private var isLiveMedia: Bool { - return (player.currentItem ?? pendingMediaItem)?.asset.duration.isIndefinite == true - } - - private var requestedPlaybackRate = 1.0 - - private let particleView = PlaylistParticleEmitter().then { - $0.isHidden = false - $0.contentMode = .scaleAspectFit - $0.clipsToBounds = true - } - - private let overlayView = UIImageView().then { - $0.contentMode = .scaleAspectFit - $0.isUserInteractionEnabled = true - $0.backgroundColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.4024561216) - } - - private let infoView = VideoPlayerInfoBar().then { - $0.layer.cornerRadius = 18.0 - $0.layer.masksToBounds = true - } - - private let controlsView = VideoPlayerControlsView().then { - $0.layer.cornerRadius = 18.0 - $0.layer.masksToBounds = true - } - - // State - private let orientation: UIInterfaceOrientation = .portrait - private var playObserver: Any? - private var fadeAnimationWorkItem: DispatchWorkItem? - - public var isPlaying: Bool { - // It is better NOT to keep tracking of isPlaying OR rate > 0.0 - // Instead we should use the timeControlStatus because PIP and Background play - // via control-center will modify the timeControlStatus property - // This will keep our UI consistent with what is on the lock-screen. - // This will also allow us to properly determine play state in - // PlaylistMediaInfo -> init -> MPRemoteCommandCenter.shared().playCommand - return player.timeControlStatus == .playing - } - private var wasPlayingBeforeSeeking = false - private(set) public var isSeeking = false - private(set) public var isFullscreen = false - private(set) public var isOverlayDisplayed = false - private(set) public var repeatState: VideoViewRepeatMode = .none - private var notificationObservers = [NSObjectProtocol]() - private var pictureInPictureObservers = [NSObjectProtocol]() - private(set) public var pictureInPictureController: AVPictureInPictureController? - - override init(frame: CGRect) { - super.init(frame: frame) - - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) - try AVAudioSession.sharedInstance().setActive(true, options: []) - } catch { - log.error(error) - } - - // Setup - backgroundColor = .black - playerLayer.player = self.player - - infoView.sidePanelButton.addTarget(self, action: #selector(onSidePanel(_:)), for: .touchUpInside) - infoView.pictureInPictureButton.addTarget(self, action: #selector(onPictureInPicture(_:)), for: .touchUpInside) - infoView.fullscreenButton.addTarget(self, action: #selector(onFullscreen(_:)), for: .touchUpInside) - infoView.exitButton.addTarget(self, action: #selector(onExitFullscreen(_:)), for: .touchUpInside) - - controlsView.repeatButton.addTarget(self, action: #selector(onRepeat(_:)), for: .touchUpInside) - controlsView.playPauseButton.addTarget(self, action: #selector(onPlay(_:)), for: .touchUpInside) - controlsView.playbackRateButton.addTarget(self, action: #selector(onPlaybackRateChanged(_:)), for: .touchUpInside) - controlsView.skipBackButton.addTarget(self, action: #selector(onSeekBackwards(_:)), for: .touchUpInside) - controlsView.skipForwardButton.addTarget(self, action: #selector(onSeekForwards(_:)), for: .touchUpInside) - controlsView.skipBackButton.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(onSeekPrevious(_:)))) - controlsView.skipForwardButton.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(onSeekNext(_:)))) - controlsView.nextButton.addTarget(self, action: #selector(onNextTrack(_:)), for: .touchUpInside) - - // Layout - layer.addSublayer(playerLayer) - addSubview(particleView) - addSubview(overlayView) - addSubview(infoView) - addSubview(controlsView) - - particleView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - overlayView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - infoView.snp.makeConstraints { - $0.leading.equalTo(self.safeArea.leading).inset(8.0) - $0.trailing.equalTo(self.safeArea.trailing).inset(8.0) - $0.top.equalTo(self.safeArea.top).inset(8.0) - } - - controlsView.snp.makeConstraints { - $0.leading.equalTo(self.safeArea.leading).inset(8.0) - $0.trailing.equalTo(self.safeArea.trailing).inset(8.0) - $0.bottom.equalTo(self.safeArea.bottom).inset(8.0) - $0.height.equalTo(100.0) - } - - registerNotifications() - registerPictureInPictureNotifications() - controlsView.trackBar.delegate = self - - let overlayTappedGesture = UITapGestureRecognizer(target: self, action: #selector(onOverlayTapped(_:))).then { - $0.numberOfTapsRequired = 1 - $0.numberOfTouchesRequired = 1 - } - - let overlayDoubleTappedGesture = UITapGestureRecognizer(target: self, action: #selector(onOverlayDoubleTapped(_:))).then { - $0.numberOfTapsRequired = 2 - $0.numberOfTouchesRequired = 1 - $0.delegate = self - } - - addGestureRecognizer(overlayTappedGesture) - addGestureRecognizer(overlayDoubleTappedGesture) - overlayTappedGesture.require(toFail: overlayDoubleTappedGesture) - - self.toggleOverlays(showOverlay: true) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .longFormAudio, options: [.allowAirPlay, .allowBluetooth, .duckOthers]) - try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - } catch { - log.error(error) - } - - if let observer = self.playObserver { - player.removeTimeObserver(observer) - } - - notificationObservers.forEach({ - NotificationCenter.default.removeObserver($0) - }) - - pictureInPictureObservers.removeAll() - } - - public override func layoutSubviews() { - super.layoutSubviews() - - playerLayer.frame = self.bounds - } - - @objc - private func onOverlayTapped(_ gestureRecognizer: UITapGestureRecognizer) { - if isSeeking { - toggleOverlays(showOverlay: true, except: [overlayView, infoView, controlsView.playPauseButton], display: [controlsView.trackBar]) - } else if (isPlaying && !isOverlayDisplayed) || (!isPlaying && !isSeeking && !isOverlayDisplayed) { - toggleOverlays(showOverlay: true) - isOverlayDisplayed = true - - fadeAnimationWorkItem?.cancel() - fadeAnimationWorkItem = DispatchWorkItem(block: { [weak self] in - guard let self = self else { return } - self.isOverlayDisplayed = false - if self.isPlaying && !self.isSeeking { - self.toggleOverlays(showOverlay: false) - } - }) - - guard let fadeAnimationWorkItem = fadeAnimationWorkItem else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: fadeAnimationWorkItem) - } else if isPlaying && isOverlayDisplayed { - toggleOverlays(showOverlay: false) - isOverlayDisplayed = false - } else { - toggleOverlays(showOverlay: true) - isOverlayDisplayed = true - } - } - - @objc - private func onOverlayDoubleTapped(_ gestureRecognizer: UITapGestureRecognizer) { - switch playerLayer.videoGravity { - case .resize: - playerLayer.videoGravity = .resizeAspect - case .resizeAspect: - playerLayer.videoGravity = .resizeAspectFill - case .resizeAspectFill: - playerLayer.videoGravity = .resizeAspect - default: - assertionFailure("Invalid VideoPlayer Gravity") - } - } - - private func seekDirectionWithAnimation(_ seekBlock: () -> Void) { - isSeeking = true - toggleOverlays(showOverlay: true) - isOverlayDisplayed = true - - seekBlock() - - fadeAnimationWorkItem?.cancel() - fadeAnimationWorkItem = DispatchWorkItem(block: { [weak self] in - guard let self = self else { return } - self.isSeeking = false - self.toggleOverlays(showOverlay: false) - self.isOverlayDisplayed = false - }) - - guard let fadeAnimationWorkItem = fadeAnimationWorkItem else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: fadeAnimationWorkItem) - } - - @objc - private func onRepeat(_ button: UIButton) { - switch repeatState { - case .none: - repeatState = .repeatOne - controlsView.repeatButton.setImage(#imageLiteral(resourceName: "playlist_repeat_one"), for: .normal) - case .repeatOne: - repeatState = .repeatAll - controlsView.repeatButton.setImage(#imageLiteral(resourceName: "playlist_repeat_all"), for: .normal) - case .repeatAll: - repeatState = .none - controlsView.repeatButton.setImage(#imageLiteral(resourceName: "playlist_repeat"), for: .normal) - } - } - - @objc - private func onPlay(_ button: UIButton) { - fadeAnimationWorkItem?.cancel() - - if self.isPlaying { - self.pause() - } else { - self.play() - } - } - - @objc - private func onPlaybackRateChanged(_ button: UIButton) { - if requestedPlaybackRate == 1.0 { - requestedPlaybackRate = 1.5 - button.setTitle("1.5x", for: .normal) - } else if requestedPlaybackRate == 1.5 { - requestedPlaybackRate = 2.0 - button.setTitle("2x", for: .normal) - } else { - requestedPlaybackRate = 1.0 - button.setTitle("1x", for: .normal) - } - - player.rate = Float(requestedPlaybackRate) - controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_pause"), for: .normal) - } - - @objc - private func onSidePanel(_ button: UIButton) { - self.delegate?.onSidePanelStateChanged() - } - - @objc - private func onPictureInPicture(_ button: UIButton) { - guard let pictureInPictureController = pictureInPictureController else { return } - - DispatchQueue.main.async { - if pictureInPictureController.isPictureInPictureActive { - self.delegate?.onPictureInPicture(enabled: false) - pictureInPictureController.stopPictureInPicture() - } else { - if #available(iOS 14.0, *) { - pictureInPictureController.requiresLinearPlayback = false - } - - self.delegate?.onPictureInPicture(enabled: true) - pictureInPictureController.startPictureInPicture() - } - } - } - - @objc - private func onFullscreen(_ button: UIButton) { - isFullscreen = true - infoView.fullscreenButton.isHidden = true - infoView.exitButton.isHidden = false - self.delegate?.onFullScreen() - } - - @objc - private func onExitFullscreen(_ button: UIButton) { - isFullscreen = false - infoView.fullscreenButton.isHidden = false - infoView.exitButton.isHidden = true - self.delegate?.onExitFullScreen() - } - - @objc - private func onSeekBackwards(_ button: UIButton) { - seekDirectionWithAnimation { - self.seekBackwards() - } - } - - @objc - private func onSeekForwards(_ button: UIButton) { - seekDirectionWithAnimation { - self.seekForwards() - } - } - - @objc - private func onSeekPrevious(_ gestureRecognizer: UIGestureRecognizer) { - if gestureRecognizer.state == .began { - self.delegate?.onPreviousTrack(isUserInitiated: true) - } - } - - @objc - private func onSeekNext(_ gestureRecognizer: UIGestureRecognizer) { - if gestureRecognizer.state == .began { - self.delegate?.onNextTrack(isUserInitiated: true) - } - } - - @objc - private func onNextTrack(_ button: UIButton) { - self.delegate?.onNextTrack(isUserInitiated: true) - } - - func onValueChanged(_ trackBar: VideoTrackerBar, value: CGFloat) { - isSeeking = true - - if isPlaying { - player.pause() - wasPlayingBeforeSeeking = true - } - - toggleOverlays(showOverlay: false, except: [infoView, controlsView], display: [controlsView]) - isOverlayDisplayed = true - - if let currentItem = player.currentItem { - let seekTime = CMTimeMakeWithSeconds(Float64(value * CGFloat(currentItem.asset.duration.value) / CGFloat(currentItem.asset.duration.timescale)), preferredTimescale: currentItem.currentTime().timescale) - player.seek(to: seekTime) - } - } - - func onValueEnded(_ trackBar: VideoTrackerBar, value: CGFloat) { - isSeeking = false - - if wasPlayingBeforeSeeking { - player.play() - player.rate = Float(requestedPlaybackRate) - wasPlayingBeforeSeeking = false - } - - if isPlaying || player.rate > 0.0 { - fadeAnimationWorkItem?.cancel() - toggleOverlays(showOverlay: false, except: [overlayView], display: [overlayView]) - overlayView.alpha = 0.0 - isOverlayDisplayed = false - } else { - fadeAnimationWorkItem?.cancel() - toggleOverlays(showOverlay: true) - overlayView.alpha = 1.0 - isOverlayDisplayed = true - } - } - - private func registerNotifications() { - notificationObservers.append(NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in - guard let self = self else { return } - - if self.pictureInPictureController?.isPictureInPictureActive == false { - self.playerLayer.player = nil - } - }) - - notificationObservers.append(NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in - guard let self = self else { return } - - if self.pictureInPictureController?.isPictureInPictureActive == false { - self.playerLayer.player = self.player - } - }) - - notificationObservers.append(NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: self.player.currentItem, queue: .main) { [weak self] _ in - guard let self = self, let currentItem = self.player.currentItem else { return } - - self.controlsView.playPauseButton.isEnabled = false - self.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_pause"), for: .normal) - self.player.pause() - - let endTime = CMTimeConvertScale(currentItem.asset.duration, timescale: self.player.currentTime().timescale, method: .roundHalfAwayFromZero) - - self.controlsView.trackBar.setTimeRange(currentTime: currentItem.currentTime(), endTime: endTime) - self.player.seek(to: .zero) - - self.controlsView.playPauseButton.isEnabled = true - self.controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_play"), for: .normal) - - self.toggleOverlays(showOverlay: true) - - self.next() - }) - - let interval = CMTimeMake(value: 25, timescale: 1000) - self.playObserver = self.player.addPeriodicTimeObserver(forInterval: interval, queue: .main, using: { [weak self] time in - guard let self = self, let currentItem = self.player.currentItem else { return } - - let endTime = CMTimeConvertScale(currentItem.asset.duration, timescale: self.player.currentTime().timescale, method: .roundHalfAwayFromZero) - - if CMTimeCompare(endTime, .zero) != 0 && endTime.value > 0 { - self.controlsView.trackBar.setTimeRange(currentTime: self.player.currentTime(), endTime: endTime) - } - }) - } - - private func registerPictureInPictureNotifications() { - if AVPictureInPictureController.isPictureInPictureSupported() { - pictureInPictureController = AVPictureInPictureController(playerLayer: self.playerLayer) - guard let pictureInPictureController = pictureInPictureController else { return } - - pictureInPictureObservers.append(pictureInPictureController.observe(\AVPictureInPictureController.isPictureInPicturePossible, options: [.initial, .new]) { [weak self] _, change in - self?.infoView.pictureInPictureButton.isEnabled = change.newValue ?? false - }) - } else { - infoView.pictureInPictureButton.isEnabled = false - } - } - - private func toggleOverlays(showOverlay: Bool) { - self.toggleOverlays(showOverlay: showOverlay, except: [], display: []) - } - - private func toggleOverlays(showOverlay: Bool, except: [UIView] = [], display: [UIView] = []) { - var except = except - var display = display - - if isVideoAvailable() { - if showOverlay { - except.append(particleView) - } - } else { - // If the overlay is showing, hide the particle view.. else show it.. - except.append(particleView) - display.append(particleView) - } - - UIView.animate(withDuration: 1.0, delay: 0.0, usingSpringWithDamping: 1.0, initialSpringVelocity: 1.0, options: [.curveEaseInOut, .allowUserInteraction], animations: { - self.subviews.forEach({ - if !except.contains($0) { - $0.alpha = showOverlay ? 1.0 : 0.0 - } else if display.contains($0) { - $0.alpha = 1.0 - } else { - $0.alpha = 0.0 - } - }) - }) - } - - public func setVideoInfo(videoDomain: String, videoTitle: String?) { - var displayTitle = videoTitle ?? "" - - if displayTitle.isEmpty { - var hostDomain = "" - - if let host = URL(string: videoDomain)?.baseDomain { - hostDomain = host - } - - if hostDomain.hasSuffix("/") { - hostDomain = String(hostDomain.dropLast()) - } - - if hostDomain.isEmpty { - hostDomain = videoDomain - } - - displayTitle = hostDomain - } - - infoView.titleLabel.text = displayTitle - infoView.updateFavIcon(domain: videoDomain) - } - - public func resetVideoInfo() { - infoView.titleLabel.text = "" - infoView.clearFavIcon() - } - - public func setControlsEnabled(_ enabled: Bool) { - // Disable all controls except the side-panel and the exit button - - infoView.fullscreenButton.isUserInteractionEnabled = enabled - infoView.pictureInPictureButton.isUserInteractionEnabled = enabled - controlsView.isUserInteractionEnabled = enabled - - gestureRecognizers?.forEach({ - $0.isEnabled = enabled - }) - } - - public func setFullscreenButtonHidden(_ hidden: Bool) { - infoView.fullscreenButton.isHidden = hidden - } - - public func setExitButtonHidden(_ hidden: Bool) { - infoView.exitButton.isHidden = hidden - } - - public func setSidePanelHidden(_ hidden: Bool) { - infoView.sidePanelButton.isHidden = hidden - } - - public func attachLayer() { - layer.insertSublayer(playerLayer, at: 0) - playerLayer.player = player - } - - public func detachLayer() { - playerLayer.removeFromSuperlayer() - playerLayer.player = nil - } - - public func play() { - if isPlaying { - toggleOverlays(showOverlay: isOverlayDisplayed) - } else { - controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_pause"), for: .normal) - player.play() - - toggleOverlays(showOverlay: false) - isOverlayDisplayed = false - } - } - - public func pause() { - if isPlaying { - controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_play"), for: .normal) - player.pause() - - toggleOverlays(showOverlay: true) - isOverlayDisplayed = true - } else { - toggleOverlays(showOverlay: isOverlayDisplayed) - } - } - - public func stop() { - controlsView.playPauseButton.setImage(#imageLiteral(resourceName: "playlist_play"), for: .normal) - player.pause() - - toggleOverlays(showOverlay: true) - isOverlayDisplayed = true - player.replaceCurrentItem(with: nil) - } - - public func seek(to time: Double) { - if let currentItem = player.currentItem { - var seekTime = time - if seekTime < 0.0 { - seekTime = 0.0 - } - - if seekTime >= currentItem.duration.seconds { - seekTime = currentItem.duration.seconds - } - - let absoluteTime = CMTimeMakeWithSeconds(seekTime, preferredTimescale: currentItem.currentTime().timescale) - player.seek(to: absoluteTime, toleranceBefore: .zero, toleranceAfter: .zero) - } - } - - public func seekBackwards() { - if let currentItem = player.currentItem { - let currentTime = currentItem.currentTime().seconds - var seekTime = currentTime - 15.0 - - if seekTime < 0 { - seekTime = 0 - } - - let absoluteTime = CMTimeMakeWithSeconds(seekTime, preferredTimescale: currentItem.currentTime().timescale) - player.seek(to: absoluteTime, toleranceBefore: .zero, toleranceAfter: .zero) - } - } - - public func seekForwards() { - if let currentItem = player.currentItem { - let currentTime = currentItem.currentTime().seconds - let seekTime = currentTime + 15.0 - - if seekTime < (currentItem.duration.seconds - 15.0) { - let absoluteTime = CMTimeMakeWithSeconds(seekTime, preferredTimescale: currentItem.currentTime().timescale) - player.seek(to: absoluteTime, toleranceBefore: .zero, toleranceAfter: .zero) - } - } - } - - public func previous() { - self.delegate?.onPreviousTrack(isUserInitiated: false) - } - - public func next() { - self.delegate?.onNextTrack(isUserInitiated: false) - } - - public func load(url: URL, resourceDelegate: AVAssetResourceLoaderDelegate?, autoPlayEnabled: Bool, completion: (() -> Void)?) { - let asset = AVURLAsset(url: url) - - if let delegate = resourceDelegate { - asset.resourceLoader.setDelegate(delegate, queue: .main) - } - - if let currentItem = player.currentItem, currentItem.asset.isKind(of: AVURLAsset.self) && player.status == .readyToPlay { - if let asset = currentItem.asset as? AVURLAsset, asset.url.absoluteString == url.absoluteString { - if isPlaying { - self.pause() - self.play() - } - - self.pendingMediaItem = nil - DispatchQueue.main.async { - completion?() - } - return - } - } - - self.pendingMediaItem = AVPlayerItem(asset: asset) - asset.loadValuesAsynchronously(forKeys: ["playable", "tracks", "duration"]) { [weak self] in - guard let self = self, let item = self.pendingMediaItem else { return } - DispatchQueue.main.async { - self.player.replaceCurrentItem(with: item) - self.pendingMediaItem = nil - - // Live media item - let isPlayingLiveMedia = self.isLiveMedia - self.controlsView.trackBar.isUserInteractionEnabled = !isPlayingLiveMedia - self.controlsView.skipBackButton.isEnabled = !isPlayingLiveMedia - self.controlsView.skipForwardButton.isEnabled = !isPlayingLiveMedia - - let endTime = CMTimeConvertScale(item.asset.duration, timescale: self.player.currentTime().timescale, method: .roundHalfAwayFromZero) - self.controlsView.trackBar.setTimeRange(currentTime: item.currentTime(), endTime: endTime) - - if autoPlayEnabled { - self.play() - } - - DispatchQueue.main.async { - completion?() - } - } - } - } - - public func load(asset: AVURLAsset, autoPlayEnabled: Bool, completion: (() -> Void)?) { - if let currentItem = player.currentItem, currentItem.asset.isKind(of: AVURLAsset.self) && player.status == .readyToPlay { - if let currentAsset = currentItem.asset as? AVURLAsset, currentAsset.url.absoluteString == asset.url.absoluteString { - if isPlaying { - self.pause() - self.play() - } - - self.pendingMediaItem = nil - DispatchQueue.main.async { - completion?() - } - return - } - } - - self.pendingMediaItem = AVPlayerItem(asset: asset) - asset.loadValuesAsynchronously(forKeys: ["playable", "tracks", "duration"]) { [weak self] in - guard let self = self, let item = self.pendingMediaItem else { return } - DispatchQueue.main.async { - self.player.replaceCurrentItem(with: item) - self.pendingMediaItem = nil - - // Live media item - let isPlayingLiveMedia = self.isLiveMedia - self.controlsView.trackBar.isUserInteractionEnabled = !isPlayingLiveMedia - self.controlsView.skipBackButton.isEnabled = !isPlayingLiveMedia - self.controlsView.skipForwardButton.isEnabled = !isPlayingLiveMedia - - let endTime = CMTimeConvertScale(item.asset.duration, timescale: self.player.currentTime().timescale, method: .roundHalfAwayFromZero) - self.controlsView.trackBar.setTimeRange(currentTime: item.currentTime(), endTime: endTime) - - if autoPlayEnabled { - self.play() - } - - DispatchQueue.main.async { - completion?() - } - } - } - } - - public func checkInsideTrackBar(point: CGPoint) -> Bool { - controlsView.trackBar.frame.contains(point) - } - - private func isAudioAvailable() -> Bool { - if let tracks = self.player.currentItem?.asset.tracks { - return tracks.filter({ $0.mediaType == .audio }).isEmpty == false - } - return false - } - - private func isVideoAvailable() -> Bool { - if let tracks = self.player.currentItem?.asset.tracks { - return tracks.isEmpty || tracks.filter({ $0.mediaType == .video }).isEmpty == false - } - - // 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. - return true - } -} - -extension VideoView: UIGestureRecognizerDelegate { - public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - let location = touch.location(in: self) - let restrictedViews = [infoView, controlsView] - for view in restrictedViews { - if view.point(inside: self.convert(location, to: view), with: nil) { - return false - } - } - return true - } -} diff --git a/Client/Frontend/Browser/PlaylistHelper.swift b/Client/Frontend/Browser/PlaylistHelper.swift index d36b1731969..1a0b63705ee 100644 --- a/Client/Frontend/Browser/PlaylistHelper.swift +++ b/Client/Frontend/Browser/PlaylistHelper.swift @@ -153,7 +153,7 @@ class PlaylistHelper: NSObject, TabContentScript { // We have no other way of knowing the playable status // It is best to assume the item can be played // In the worst case, if it can't be played, it will show an error - completion(true) + completion(isAssetPlayable()) } case .online: // Fetch the playable status asynchronously diff --git a/Data/models/PlaylistItem.swift b/Data/models/PlaylistItem.swift index a7c992d960e..21681c2bd0c 100644 --- a/Data/models/PlaylistItem.swift +++ b/Data/models/PlaylistItem.swift @@ -35,6 +35,20 @@ final public class PlaylistItem: NSManagedObject, CRUD { sectionNameKeyPath: nil, cacheName: nil) } + public class func backgroundFrc() -> NSFetchedResultsController { + let context = DataController.newBackgroundContext() + let fetchRequest = NSFetchRequest() + fetchRequest.entity = PlaylistItem.entity(context) + fetchRequest.fetchBatchSize = 20 + + let orderSort = NSSortDescriptor(key: "order", ascending: true) + let createdSort = NSSortDescriptor(key: "dateAdded", ascending: false) + fetchRequest.sortDescriptors = [orderSort, createdSort] + + return NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, + sectionNameKeyPath: nil, cacheName: nil) + } + public static func addItem(_ item: PlaylistInfo, cachedData: Data?, completion: (() -> Void)? = nil) { DataController.perform(context: .new(inMemory: false), save: false) { context in let playlistItem = PlaylistItem(context: context) From 347cb6d77e65b2c0e9599cda925580b523340878 Mon Sep 17 00:00:00 2001 From: Brandon T Date: Thu, 26 Aug 2021 10:52:32 -0400 Subject: [PATCH 2/7] Addressing some feedback. --- .../Browser/Playlist/Utilities/PlaylistMediaStreamer.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Client/Frontend/Browser/Playlist/Utilities/PlaylistMediaStreamer.swift b/Client/Frontend/Browser/Playlist/Utilities/PlaylistMediaStreamer.swift index fd27d24f8ec..d54301a0cdc 100644 --- a/Client/Frontend/Browser/Playlist/Utilities/PlaylistMediaStreamer.swift +++ b/Client/Frontend/Browser/Playlist/Utilities/PlaylistMediaStreamer.swift @@ -125,11 +125,8 @@ class PlaylistMediaStreamer { MPMediaItemPropertyArtist: URL(string: item.pageSrc)?.baseDomain ?? item.pageSrc, MPMediaItemPropertyPlaybackDuration: item.duration, MPNowPlayingInfoPropertyPlaybackProgress: 0.0, -// MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0, MPNowPlayingInfoPropertyAssetURL: URL(string: item.pageSrc) as Any, - MPNowPlayingInfoPropertyElapsedPlaybackTime: 0.0, //player.currentTime.seconds - //MPNowPlayingInfoPropertyPlaybackQueueIndex: 0, - //MPNowPlayingInfoPropertyPlaybackQueueCount: 0 + MPNowPlayingInfoPropertyElapsedPlaybackTime: 0.0, ] } From a8ba3395d29166e900fa15a4825e21966dc0a684 Mon Sep 17 00:00:00 2001 From: Brandon T Date: Thu, 26 Aug 2021 10:56:47 -0400 Subject: [PATCH 3/7] Localized strings. --- BraveShared/BraveStrings.swift | 6 ++++++ .../Playlist/Controllers/PlaylistCarplayController.swift | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/BraveShared/BraveStrings.swift b/BraveShared/BraveStrings.swift index 33d6c4cd67f..e7fac18bc67 100644 --- a/BraveShared/BraveStrings.swift +++ b/BraveShared/BraveStrings.swift @@ -1407,6 +1407,12 @@ extension Strings { bundle: .braveShared, value: "Remove", comment: "Button title in the popover when an item is already in your playlist and you tap the 'Add to Playlist' button in the URL bar") + + public static let playlistCarplayTitle = + NSLocalizedString("playlist.carplayTitle", + bundle: .braveShared, + value: "Brave Playlist", + comment: "The title of the playlist when in Carplay mode") } } diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift index 12b832ed4f3..235ffc0940d 100644 --- a/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift @@ -214,7 +214,7 @@ extension PlaylistCarplayController: MPPlayableContentDataSource { // Tab Section if indexPath.count == 1 { let item = MPContentItem(identifier: "BravePlaylist") - item.title = "Brave Playlist" + item.title = Strings.PlayList.playlistCarplayTitle item.isContainer = true item.isPlayable = false let imageIcon = #imageLiteral(resourceName: "settings-shields") From 4a2002028d3dae41d4b735d37b19ebe3417296d7 Mon Sep 17 00:00:00 2001 From: Brandon T Date: Thu, 26 Aug 2021 11:02:56 -0400 Subject: [PATCH 4/7] Fixed seeking to last played time. --- .../Playlist/Controllers/PlaylistListViewController..swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController..swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController..swift index 21fbdd713af..b3cf296f6c8 100644 --- a/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController..swift +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistListViewController..swift @@ -245,7 +245,7 @@ class PlaylistListViewController: UIViewController { lastPlayedTime > 0.0 && lastPlayedTime < delegate?.currentPlaylistAsset?.duration.seconds ?? 0.0 && Preferences.Playlist.playbackLeftOff.value { - self.playerView.seek(to: Preferences.Playlist.lastPlayedItemTime.value) + self.playerView.seek(to: lastPlayedTime) } } From 51ade41df65cf5f0f38cb63a91f483d71024f9f6 Mon Sep 17 00:00:00 2001 From: Brandon T Date: Thu, 26 Aug 2021 11:20:24 -0400 Subject: [PATCH 5/7] Fix conflict with background playback and playlist. --- .../PlaylistCarplayManager.swift | 11 ++++++++ Client/Frontend/Browser/PlaylistHelper.swift | 18 +++++++++++-- .../Frontend/Browser/UserScriptManager.swift | 1 + .../UserContent/UserScripts/Playlist.js | 25 ++++++++++++++++--- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift b/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift index 32a7d5c45ce..07cd54bc745 100644 --- a/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift +++ b/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistCarplayManager.swift @@ -8,6 +8,7 @@ import Combine import MediaPlayer import Shared import Data +import BraveShared private let log = Logger.browserLogger @@ -75,6 +76,16 @@ class PlaylistCarplayManager: NSObject { } func getPlaylistController(initialItem: PlaylistInfo?, initialItemPlaybackOffset: Double) -> PlaylistViewController { + + // If background playback is enabled, tabs will continue to play media + // Even if another controller is presented and even when PIP is enabled in playlist. + // Therefore we need to stop the page/tab from playing when using playlist. + if Preferences.General.mediaAutoBackgrounding.value { + browserController?.tabManager.allTabs.forEach({ + PlaylistHelper.stopPlayback(tab: $0) + }) + } + // If there is no media player, create one, // pass it to the play-list controller let mediaPlayer = self.mediaPlayer ?? MediaPlayer() diff --git a/Client/Frontend/Browser/PlaylistHelper.swift b/Client/Frontend/Browser/PlaylistHelper.swift index 1a0b63705ee..a76a28a52c6 100644 --- a/Client/Frontend/Browser/PlaylistHelper.swift +++ b/Client/Frontend/Browser/PlaylistHelper.swift @@ -199,7 +199,7 @@ extension PlaylistHelper: UIGestureRecognizerDelegate { let touchPoint = gestureRecognizer.location(in: webView) let token = UserScriptManager.securityToken.uuidString.replacingOccurrences(of: "-", with: "", options: .literal) - let javascript = String(format: "window.onLongPressActivated_%@(%f, %f)", token, touchPoint.x, touchPoint.y) + let javascript = String(format: "window.__firefox__.onLongPressActivated_%@(%f, %f)", token, touchPoint.x, touchPoint.y) webView.evaluateJavaScript(javascript) // swiftlint:disable:this safe_javascript } } @@ -219,7 +219,7 @@ extension PlaylistHelper: UIGestureRecognizerDelegate { extension PlaylistHelper { static func getCurrentTime(webView: WKWebView, nodeTag: String, completion: @escaping (Double) -> Void) { let token = UserScriptManager.securityTokenString - let javascript = String(format: "window.mediaCurrentTimeFromTag_%@('%@')", token, nodeTag) + let javascript = String(format: "window.__firefox__.mediaCurrentTimeFromTag_%@('%@')", token, nodeTag) // swiftlint:disable:next safe_javascript webView.evaluateJavaScript(javascript, completionHandler: { value, error in @@ -236,4 +236,18 @@ extension PlaylistHelper { } }) } + + static func stopPlayback(tab: Tab?) { + guard let tab = tab else { return } + + let token = UserScriptManager.securityTokenString + let javascript = String(format: "window.__firefox__.stopMediaPlayback_%@()", token) + + // swiftlint:disable:next safe_javascript + tab.webView?.evaluateJavaScript(javascript, completionHandler: { value, error in + if let error = error { + log.error("Error Retrieving Stopping Media Playback: \(error)") + } + }) + } } diff --git a/Client/Frontend/Browser/UserScriptManager.swift b/Client/Frontend/Browser/UserScriptManager.swift index f497f1a9635..8e25aa43df1 100644 --- a/Client/Frontend/Browser/UserScriptManager.swift +++ b/Client/Frontend/Browser/UserScriptManager.swift @@ -293,6 +293,7 @@ class UserScriptManager { "$": "tagNode_\(token)", "$": "tagUUID_\(token)", "$": "mediaCurrentTimeFromTag_\(token)", + "$": "stopMediaPlayback_\(token)", "$": "playlistHelper_sendMessage_\(token)", "$": "playlistHelper_\(messageHandlerTokenString)", "$": "notify_\(token)", diff --git a/Client/Frontend/UserContent/UserScripts/Playlist.js b/Client/Frontend/UserContent/UserScripts/Playlist.js index 61c624bd4e7..b83634084c0 100644 --- a/Client/Frontend/UserContent/UserScripts/Playlist.js +++ b/Client/Frontend/UserContent/UserScripts/Playlist.js @@ -119,9 +119,9 @@ window.__firefox__.includeOnce("Playlist", function() { } function $() { - Object.defineProperty(window, '$', { + Object.defineProperty(window.__firefox__, '$', { enumerable: false, - configurable: false, + configurable: true, value: function(localX, localY) { function execute(page, offsetX, offsetY) { @@ -403,9 +403,9 @@ window.__firefox__.includeOnce("Playlist", function() { } function $() { - Object.defineProperty(window, '$', { + Object.defineProperty(window.__firefox__, '$', { enumerable: false, - configurable: false, + configurable: true, value: function(tag) { for (element of document.querySelectorAll('video')) { @@ -423,6 +423,23 @@ window.__firefox__.includeOnce("Playlist", function() { return 0.0; } }); + + Object.defineProperty(window.__firefox__, '$', { + enumerable: false, + configurable: true, + value: + function(tag) { + for (element of document.querySelectorAll('video')) { + element.pause(); + } + + for (element of document.querySelectorAll('audio')) { + element.pause(); + } + + return 0.0; + } + }); } // MARK: ----------------------------- From 89e6c23888bb4f75002c5a21141a8ad0abb45cb4 Mon Sep 17 00:00:00 2001 From: Brandon T Date: Thu, 26 Aug 2021 11:34:12 -0400 Subject: [PATCH 6/7] Adding comments to the code. --- .../Playlist/Controllers/PlaylistCarplayController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift index 235ffc0940d..0367df61186 100644 --- a/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift @@ -562,6 +562,10 @@ extension PlaylistCarplayController { } } +// MPContentItem has no way of storing a thumbnail with it. +// So to do that, we need associated values where we store its renderer. +// The next time it attempts to retrieve its thumbnail, we return it from the renderer. +// Otherwise it will constantly make requests for thumbnail :( extension MPContentItem { func loadThumbnail(for mediaItem: PlaylistInfo) { From 6399b4c878a3d40282bb51630fc34ed67eb52dbd Mon Sep 17 00:00:00 2001 From: Brandon T Date: Thu, 26 Aug 2021 16:03:07 -0400 Subject: [PATCH 7/7] Fixing playback for carplay! Working almost perfectly. --- .../PlaylistCarplayController.swift | 25 +++++++------------ .../PlaylistCarplayManager.swift | 9 ++++++- .../Managers & Cache/PlaylistManager.swift | 4 +++ .../Playlist/VideoPlayer/MediaPlayer.swift | 4 +-- Data/models/PlaylistItem.swift | 14 ----------- 5 files changed, 23 insertions(+), 33 deletions(-) diff --git a/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift b/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift index 0367df61186..7fa68957cd8 100644 --- a/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift +++ b/Client/Frontend/Browser/Playlist/Controllers/PlaylistCarplayController.swift @@ -35,9 +35,7 @@ class PlaylistCarplayController: NSObject { observePlaylistStates() PlaylistManager.shared.reloadData() - playlistItemIds = (0.. PlaylistCarplayController { diff --git a/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistManager.swift b/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistManager.swift index 4d9fe0153f5..ce587a02097 100644 --- a/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistManager.swift +++ b/Client/Frontend/Browser/Playlist/Managers & Cache/PlaylistManager.swift @@ -72,6 +72,10 @@ class PlaylistManager: NSObject { onDownloadStateChanged.eraseToAnyPublisher() } + var allItems: [PlaylistInfo] { + frc.fetchedObjects?.map({ PlaylistInfo(item: $0) }) ?? [] + } + var numberOfAssets: Int { frc.fetchedObjects?.count ?? 0 } diff --git a/Client/Frontend/Browser/Playlist/VideoPlayer/MediaPlayer.swift b/Client/Frontend/Browser/Playlist/VideoPlayer/MediaPlayer.swift index 50c01ebf860..f6d952b0442 100644 --- a/Client/Frontend/Browser/Playlist/VideoPlayer/MediaPlayer.swift +++ b/Client/Frontend/Browser/Playlist/VideoPlayer/MediaPlayer.swift @@ -183,7 +183,7 @@ class MediaPlayer: NSObject { func stop() { if isPlaying { - previousRate = 0.0 + previousRate = player.rate == 0.0 ? -1.0 : player.rate player.pause() player.replaceCurrentItem(with: nil) stopSubscriber.send(EventNotification(mediaPlayer: self, event: .stop)) @@ -297,7 +297,7 @@ class MediaPlayer: NSObject { } func setPlaybackRate(rate: Float) { - previousRate = player.rate + previousRate = player.rate == 0.0 ? -1.0 : player.rate player.rate = rate changePlaybackRateSubscriber.send(EventNotification(mediaPlayer: self, event: .changePlaybackRate)) diff --git a/Data/models/PlaylistItem.swift b/Data/models/PlaylistItem.swift index 21681c2bd0c..a7c992d960e 100644 --- a/Data/models/PlaylistItem.swift +++ b/Data/models/PlaylistItem.swift @@ -35,20 +35,6 @@ final public class PlaylistItem: NSManagedObject, CRUD { sectionNameKeyPath: nil, cacheName: nil) } - public class func backgroundFrc() -> NSFetchedResultsController { - let context = DataController.newBackgroundContext() - let fetchRequest = NSFetchRequest() - fetchRequest.entity = PlaylistItem.entity(context) - fetchRequest.fetchBatchSize = 20 - - let orderSort = NSSortDescriptor(key: "order", ascending: true) - let createdSort = NSSortDescriptor(key: "dateAdded", ascending: false) - fetchRequest.sortDescriptors = [orderSort, createdSort] - - return NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, - sectionNameKeyPath: nil, cacheName: nil) - } - public static func addItem(_ item: PlaylistInfo, cachedData: Data?, completion: (() -> Void)? = nil) { DataController.perform(context: .new(inMemory: false), save: false) { context in let playlistItem = PlaylistItem(context: context)