From 32bf7d9d27bb5890a019056ae005988e01ac4388 Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Thu, 16 May 2024 16:36:26 +0300 Subject: [PATCH] [iOS] Completion doesn't work for videos in PiP mode. (#414) * chore: player refactor * chore: removed url property * chore: fixed completion and rate view for player * chore: removed commented code * chore: added error handler * chore: refactor * chore: refactor * chore: refactor * chore: refactor * chore: refactor * chore: tests * chore: refactor * chore: review requested changes * chore: merge conflict resolve --- Core/Core/Data/Model/UserSettings.swift | 13 + Course/Course.xcodeproj/project.pbxproj | 32 +- .../Unit/Subviews/YouTubeView.swift | 2 +- .../Video/EncodedVideoPlayer.swift | 61 +--- .../Video/EncodedVideoPlayerViewModel.swift | 78 +---- .../Video/PipManagerProtocol.swift | 53 +++ .../Video/PlayerControllerProtocol.swift | 15 + .../Video/PlayerDelegateProtocol.swift | 62 ++++ .../Video/PlayerServiceProtocol.swift | 63 ++++ .../Video/PlayerTrackerProtocol.swift | 318 ++++++++++++++++++ .../Video/PlayerViewController.swift | 122 +------ .../Video/PlayerViewControllerHolder.swift | 237 ++++++++----- ...btittlesView.swift => SubtitlesView.swift} | 15 +- .../Video/VideoPlayerViewModel.swift | 99 ++++-- .../Video/YouTubeVideoPlayer.swift | 10 +- .../Video/YouTubeVideoPlayerViewModel.swift | 135 +------- .../YoutubePlayerViewControllerHolder.swift | 183 ++++++++++ .../Unit/VideoPlayerViewModelTests.swift | 87 +++-- OpenEdX/DI/ScreenAssembly.swift | 105 ++++-- OpenEdX/Managers/PipManager.swift | 51 ++- 20 files changed, 1145 insertions(+), 596 deletions(-) create mode 100644 Course/Course/Presentation/Video/PipManagerProtocol.swift create mode 100644 Course/Course/Presentation/Video/PlayerControllerProtocol.swift create mode 100644 Course/Course/Presentation/Video/PlayerDelegateProtocol.swift create mode 100644 Course/Course/Presentation/Video/PlayerServiceProtocol.swift create mode 100644 Course/Course/Presentation/Video/PlayerTrackerProtocol.swift rename Course/Course/Presentation/Video/{SubtittlesView.swift => SubtitlesView.swift} (93%) create mode 100644 Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift diff --git a/Core/Core/Data/Model/UserSettings.swift b/Core/Core/Data/Model/UserSettings.swift index 1b25e9d6c..52ce12557 100644 --- a/Core/Core/Data/Model/UserSettings.swift +++ b/Core/Core/Data/Model/UserSettings.swift @@ -32,6 +32,19 @@ public enum StreamingQuality: Codable { public var value: String? { return String(describing: self).components(separatedBy: "(").first } + + public var resolution: CGSize { + switch self { + case .auto: + return CGSize(width: 1280, height: 720) + case .low: + return CGSize(width: 640, height: 360) + case .medium: + return CGSize(width: 854, height: 480) + case .high: + return CGSize(width: 1280, height: 720) + } + } } public enum DownloadQuality: Codable, CaseIterable { diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 6ecf45e64..ed692cdd8 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -48,7 +48,6 @@ 02D4FC2E2BBD7C9C00C47748 /* MessageSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */; }; 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0144E28F46474002E513D /* CourseContainerView.swift */; }; 02F0145728F4A2FF002E513D /* CourseContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */; }; - 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F066E729DC71750073E13B /* SubtittlesView.swift */; }; 02F3BFDD29252E900051930C /* CourseRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFDC29252E900051930C /* CourseRouter.swift */; }; 02F78AEB29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F78AEA29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift */; }; 02F98A8128F8224200DE94C0 /* Discussion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F98A8028F8224200DE94C0 /* Discussion.framework */; }; @@ -56,6 +55,13 @@ 02FFAD0D29E4347300140E46 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FFAD0C29E4347300140E46 /* VideoPlayerViewModel.swift */; }; 060E8BCA2B5FD68C0080C952 /* UnitStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 060E8BC92B5FD68C0080C952 /* UnitStack.swift */; }; 065275352BB1B39C0093BCCA /* PlayerViewControllerHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 065275342BB1B39C0093BCCA /* PlayerViewControllerHolder.swift */; }; + 067B7B4E2BED339200D1768F /* PlayerTrackerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B472BED339200D1768F /* PlayerTrackerProtocol.swift */; }; + 067B7B4F2BED339200D1768F /* PlayerDelegateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B482BED339200D1768F /* PlayerDelegateProtocol.swift */; }; + 067B7B502BED339200D1768F /* PlayerControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B492BED339200D1768F /* PlayerControllerProtocol.swift */; }; + 067B7B512BED339200D1768F /* PipManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B4A2BED339200D1768F /* PipManagerProtocol.swift */; }; + 067B7B522BED339200D1768F /* SubtitlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B4B2BED339200D1768F /* SubtitlesView.swift */; }; + 067B7B532BED339200D1768F /* PlayerServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B4C2BED339200D1768F /* PlayerServiceProtocol.swift */; }; + 067B7B542BED339200D1768F /* YoutubePlayerViewControllerHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B4D2BED339200D1768F /* YoutubePlayerViewControllerHolder.swift */; }; 068DDA5F2B1E198700FF8CCB /* CourseUnitDropDownList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068DDA5B2B1E198700FF8CCB /* CourseUnitDropDownList.swift */; }; 068DDA602B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068DDA5C2B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift */; }; 068DDA612B1E198700FF8CCB /* CourseUnitDropDownCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068DDA5D2B1E198700FF8CCB /* CourseUnitDropDownCell.swift */; }; @@ -147,7 +153,6 @@ 02ED50CF29A64BB6008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F0144E28F46474002E513D /* CourseContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerView.swift; sourceTree = ""; }; 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerViewModel.swift; sourceTree = ""; }; - 02F066E729DC71750073E13B /* SubtittlesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtittlesView.swift; sourceTree = ""; }; 02F3BFDC29252E900051930C /* CourseRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRouter.swift; sourceTree = ""; }; 02F78AEA29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = VideoPlayerViewModelTests.swift; path = CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift; sourceTree = SOURCE_ROOT; }; 02F98A8028F8224200DE94C0 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -155,6 +160,13 @@ 02FFAD0C29E4347300140E46 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; 060E8BC92B5FD68C0080C952 /* UnitStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitStack.swift; sourceTree = ""; }; 065275342BB1B39C0093BCCA /* PlayerViewControllerHolder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerViewControllerHolder.swift; sourceTree = ""; }; + 067B7B472BED339200D1768F /* PlayerTrackerProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerTrackerProtocol.swift; sourceTree = ""; }; + 067B7B482BED339200D1768F /* PlayerDelegateProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerDelegateProtocol.swift; sourceTree = ""; }; + 067B7B492BED339200D1768F /* PlayerControllerProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerControllerProtocol.swift; sourceTree = ""; }; + 067B7B4A2BED339200D1768F /* PipManagerProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PipManagerProtocol.swift; sourceTree = ""; }; + 067B7B4B2BED339200D1768F /* SubtitlesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubtitlesView.swift; sourceTree = ""; }; + 067B7B4C2BED339200D1768F /* PlayerServiceProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerServiceProtocol.swift; sourceTree = ""; }; + 067B7B4D2BED339200D1768F /* YoutubePlayerViewControllerHolder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = YoutubePlayerViewControllerHolder.swift; sourceTree = ""; }; 068DDA5B2B1E198700FF8CCB /* CourseUnitDropDownList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseUnitDropDownList.swift; sourceTree = ""; }; 068DDA5C2B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseUnitVerticalsDropdownView.swift; sourceTree = ""; }; 068DDA5D2B1E198700FF8CCB /* CourseUnitDropDownCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseUnitDropDownCell.swift; sourceTree = ""; }; @@ -459,8 +471,14 @@ 070019AA28F6F79E00D5FC78 /* Video */ = { isa = PBXGroup; children = ( + 067B7B4A2BED339200D1768F /* PipManagerProtocol.swift */, + 067B7B492BED339200D1768F /* PlayerControllerProtocol.swift */, + 067B7B482BED339200D1768F /* PlayerDelegateProtocol.swift */, + 067B7B4C2BED339200D1768F /* PlayerServiceProtocol.swift */, + 067B7B472BED339200D1768F /* PlayerTrackerProtocol.swift */, + 067B7B4B2BED339200D1768F /* SubtitlesView.swift */, + 067B7B4D2BED339200D1768F /* YoutubePlayerViewControllerHolder.swift */, 065275342BB1B39C0093BCCA /* PlayerViewControllerHolder.swift */, - 02F066E729DC71750073E13B /* SubtittlesView.swift */, 0766DFCB299AA7A600EBEF6A /* YouTubeVideoPlayer.swift */, 022F8E152A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift */, 0766DFCD299AB26D00EBEF6A /* EncodedVideoPlayer.swift */, @@ -834,6 +852,7 @@ buildActionMask = 2147483647; files = ( 06FD7EE32B1F3FF6008D632B /* DropdownAnimationModifier.swift in Sources */, + 067B7B542BED339200D1768F /* YoutubePlayerViewControllerHolder.swift in Sources */, 02FFAD0D29E4347300140E46 /* VideoPlayerViewModel.swift in Sources */, 02454CA42A26193F0043052A /* WebView.swift in Sources */, 022C64DA29ACEC50000F532B /* HandoutsViewModel.swift in Sources */, @@ -844,6 +863,7 @@ BA58CF612B471041005B102E /* VideoDownloadQualityBarView.swift in Sources */, 0270210328E736E700F54332 /* CourseOutlineView.swift in Sources */, 068DDA602B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift in Sources */, + 067B7B512BED339200D1768F /* PipManagerProtocol.swift in Sources */, 022C64E029ADEA9B000F532B /* Data_UpdatesResponse.swift in Sources */, 02D4FC2E2BBD7C9C00C47748 /* MessageSectionView.swift in Sources */, 02454CA02A2618E70043052A /* YouTubeView.swift in Sources */, @@ -874,20 +894,24 @@ BAAD62C82AFD00EE000E6103 /* CourseStructureNestedListView.swift in Sources */, 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */, 97E7DF0F2B7C852A00A2A09B /* DatesStatusInfoView.swift in Sources */, + 067B7B4F2BED339200D1768F /* PlayerDelegateProtocol.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, + 067B7B532BED339200D1768F /* PlayerServiceProtocol.swift in Sources */, BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */, 060E8BCA2B5FD68C0080C952 /* UnitStack.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, 06FD7EDF2B1F29F3008D632B /* CourseVerticalImageView.swift in Sources */, BAC0E0DB2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift in Sources */, DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */, - 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */, + 067B7B522BED339200D1768F /* SubtitlesView.swift in Sources */, 07DE59862BECB868001CBFBC /* CourseAnalytics.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, BA58CF642B471363005B102E /* VideoDownloadQualityContainerView.swift in Sources */, BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */, + 067B7B502BED339200D1768F /* PlayerControllerProtocol.swift in Sources */, + 067B7B4E2BED339200D1768F /* PlayerTrackerProtocol.swift in Sources */, 02454CA62A26196C0043052A /* UnknownView.swift in Sources */, 0766DFD0299AB29000EBEF6A /* PlayerViewController.swift in Sources */, 022C64DC29ACFDEE000F532B /* Data_HandoutsResponse.swift in Sources */, diff --git a/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift b/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift index 934d75803..3be341524 100644 --- a/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift +++ b/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift @@ -24,7 +24,7 @@ struct YouTubeView: View { var body: some View { let vm = Container.shared.resolve( YouTubeVideoPlayerViewModel.self, - arguments: url, + arguments: URL(string: url), blockID, courseID, languages, diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index cdea26e70..72e0c5dde 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -27,10 +27,7 @@ public struct EncodedVideoPlayer: View { @State private var orientation = UIDevice.current.orientation @State private var isLoading: Bool = true @State private var isAnimating: Bool = false - @State private var isViewedOnce: Bool = false - @State private var currentTime: Double = 0 @State private var isOrientationChanged: Bool = false - @State private var pause: Bool = false @State var showAlert = false @State var alertMessage: String? { @@ -57,32 +54,13 @@ public struct EncodedVideoPlayer: View { VStack(spacing: 10) { HStack { VStack { - PlayerViewController( - videoURL: viewModel.url, - playerHolder: viewModel.controllerHolder, - bitrate: viewModel.getVideoResolution(), - progress: { progress in - if progress >= 0.8 { - if !isViewedOnce { - Task { - await viewModel.blockCompletionRequest() - } - isViewedOnce = true - } - } - if progress == 1 { - viewModel.router.presentAppReview() - } - - }, seconds: { seconds in - currentTime = seconds - }) + PlayerViewController(playerController: viewModel.controller) .aspectRatio(16 / 9, contentMode: .fit) .frame(minWidth: playerWidth(for: reader.size)) .cornerRadius(12) .onAppear { - if !viewModel.controllerHolder.isPlayingInPip, - !viewModel.controllerHolder.isOtherPlayerInPip { + if !viewModel.isPlayingInPip, + !viewModel.isOtherPlayerInPip { viewModel.controller.player?.play() } } @@ -91,9 +69,9 @@ public struct EncodedVideoPlayer: View { } } if isHorizontal { - SubtittlesView( + SubtitlesView( languages: viewModel.languages, - currentTime: $currentTime, + currentTime: $viewModel.currentTime, viewModel: viewModel, scrollTo: { date in viewModel.controller.player?.seek( @@ -103,15 +81,15 @@ public struct EncodedVideoPlayer: View { ) ) viewModel.controller.player?.play() - pauseScrolling() - currentTime = (date.secondsSinceMidnight() + 1) + viewModel.pauseScrolling() + viewModel.currentTime = (date.secondsSinceMidnight() + 1) }) } } if !isHorizontal { - SubtittlesView( + SubtitlesView( languages: viewModel.languages, - currentTime: $currentTime, + currentTime: $viewModel.currentTime, viewModel: viewModel, scrollTo: { date in viewModel.controller.player?.seek( @@ -121,8 +99,8 @@ public struct EncodedVideoPlayer: View { ) ) viewModel.controller.player?.play() - pauseScrolling() - currentTime = (date.secondsSinceMidnight() + 1) + viewModel.pauseScrolling() + viewModel.currentTime = (date.secondsSinceMidnight() + 1) }) } } @@ -134,17 +112,11 @@ public struct EncodedVideoPlayer: View { viewModel.controller.player?.allowsExternalPlayback = false } .onAppear { + viewModel.controller.player?.allowsExternalPlayback = true viewModel.controller.setNeedsStatusBarAppearanceUpdate() } } - private func pauseScrolling() { - pause = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.pause = false - } - } - private func playerWidth(for size: CGSize) -> CGFloat { if isHorizontal { return size.width * 0.6 @@ -163,17 +135,10 @@ struct EncodedVideoPlayer_Previews: PreviewProvider { static var previews: some View { EncodedVideoPlayer( viewModel: EncodedVideoPlayerViewModel( - url: URL(string: "")!, - blockID: "", - courseID: "", languages: [], playerStateSubject: CurrentValueSubject(nil), - interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), - appStorage: CoreStorageMock(), connectivity: Connectivity(), - pipManager: PipManagerProtocolMock(), - selectedCourseTab: 0 + playerHolder: PlayerViewControllerHolder.mock ), isOnScreen: true ) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift index bb5eb8d3e..b7bdaed9f 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -10,83 +10,7 @@ import Core import Combine public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { - - let url: URL? - - let controllerHolder: PlayerViewControllerHolder var controller: AVPlayerViewController { - controllerHolder.playerController - } - private var subscription = Set() - - public init( - url: URL?, - blockID: String, - courseID: String, - languages: [SubtitleUrl], - playerStateSubject: CurrentValueSubject, - interactor: CourseInteractorProtocol, - router: CourseRouter, - appStorage: CoreStorage, - connectivity: ConnectivityProtocol, - pipManager: PipManagerProtocol, - selectedCourseTab: Int - ) { - self.url = url - - if let holder = pipManager.holder( - for: url, - blockID: blockID, - courseID: courseID, - selectedCourseTab: selectedCourseTab - ) { - controllerHolder = holder - } else { - let holder = PlayerViewControllerHolder( - url: url, - blockID: blockID, - courseID: courseID, - selectedCourseTab: selectedCourseTab - ) - controllerHolder = holder - } - - super.init(blockID: blockID, - courseID: courseID, - languages: languages, - interactor: interactor, - router: router, - appStorage: appStorage, - connectivity: connectivity) - - playerStateSubject.sink(receiveValue: { [weak self] state in - switch state { - case .pause: - if self?.controllerHolder.isPlayingInPip != true { - self?.controller.player?.pause() - } - case .kill: - if self?.controllerHolder.isPlayingInPip != true { - self?.controller.player?.replaceCurrentItem(with: nil) - } - case .none: - break - } - }).store(in: &subscription) - } - - func getVideoResolution() -> CGSize { - switch appStorage.userSettings?.streamingQuality { - case .auto: - return CGSize(width: 1280, height: 720) - case .low: - return CGSize(width: 640, height: 360) - case .medium: - return CGSize(width: 854, height: 480) - case .high: - return CGSize(width: 1280, height: 720) - case .none: - return CGSize(width: 1280, height: 720) - } + (playerHolder.playerController as? AVPlayerViewController) ?? AVPlayerViewController() } } diff --git a/Course/Course/Presentation/Video/PipManagerProtocol.swift b/Course/Course/Presentation/Video/PipManagerProtocol.swift new file mode 100644 index 000000000..c92550cb3 --- /dev/null +++ b/Course/Course/Presentation/Video/PipManagerProtocol.swift @@ -0,0 +1,53 @@ +// +// PipManagerProtocol.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import Combine +import Foundation + +public protocol PipManagerProtocol { + var isPipActive: Bool { get } + var isPipPlaying: Bool { get } + + func holder( + for url: URL?, + blockID: String, + courseID: String, + selectedCourseTab: Int + ) -> PlayerViewControllerHolderProtocol? + func set(holder: PlayerViewControllerHolderProtocol) + func remove(holder: PlayerViewControllerHolderProtocol) + func restore(holder: PlayerViewControllerHolderProtocol) async throws + func pipRatePublisher() -> AnyPublisher? + func pauseCurrentPipVideo() +} + +#if DEBUG +public class PipManagerProtocolMock: PipManagerProtocol { + public var isPipActive: Bool { + false + } + + public var isPipPlaying: Bool { + false + } + + public init() {} + public func holder( + for url: URL?, + blockID: String, + courseID: String, + selectedCourseTab: Int + ) -> PlayerViewControllerHolderProtocol? { + return nil + } + public func set(holder: PlayerViewControllerHolderProtocol) {} + public func remove(holder: PlayerViewControllerHolderProtocol) {} + public func restore(holder: PlayerViewControllerHolderProtocol) async throws {} + public func pipRatePublisher() -> AnyPublisher? { nil } + public func pauseCurrentPipVideo() {} +} +#endif diff --git a/Course/Course/Presentation/Video/PlayerControllerProtocol.swift b/Course/Course/Presentation/Video/PlayerControllerProtocol.swift new file mode 100644 index 000000000..df376e466 --- /dev/null +++ b/Course/Course/Presentation/Video/PlayerControllerProtocol.swift @@ -0,0 +1,15 @@ +// +// PlayerControllerProtocol.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import Foundation + +public protocol PlayerControllerProtocol { + func play() + func pause() + func seekTo(to date: Date) + func stop() +} diff --git a/Course/Course/Presentation/Video/PlayerDelegateProtocol.swift b/Course/Course/Presentation/Video/PlayerDelegateProtocol.swift new file mode 100644 index 000000000..1297e9ddf --- /dev/null +++ b/Course/Course/Presentation/Video/PlayerDelegateProtocol.swift @@ -0,0 +1,62 @@ +// +// PlayerDelegateProtocol.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import AVKit + +public protocol PlayerDelegateProtocol: AVPlayerViewControllerDelegate { + var isPlayingInPip: Bool { get } + var playerHolder: PlayerViewControllerHolderProtocol? { get set } + init(pipManager: PipManagerProtocol) +} + +public class PlayerDelegate: NSObject, PlayerDelegateProtocol { + private(set) public var isPlayingInPip: Bool = false + private let pipManager: PipManagerProtocol + weak public var playerHolder: PlayerViewControllerHolderProtocol? + + required public init(pipManager: PipManagerProtocol) { + self.pipManager = pipManager + super.init() + } + + public func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { + isPlayingInPip = true + if let holder = playerHolder { + pipManager.set(holder: holder) + } + } + + public func playerViewController( + _ playerViewController: AVPlayerViewController, + failedToStartPictureInPictureWithError error: any Error + ) { + isPlayingInPip = false + if let holder = playerHolder { + pipManager.remove(holder: holder) + } + } + + public func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { + isPlayingInPip = false + if let holder = playerHolder { + pipManager.remove(holder: holder) + } + } + + public func playerViewControllerRestoreUserInterfaceForPictureInPictureStop( + _ playerViewController: AVPlayerViewController + ) async -> Bool { + do { + if let holder = playerHolder { + try await pipManager.restore(holder: holder) + } + return true + } catch { + return false + } + } +} diff --git a/Course/Course/Presentation/Video/PlayerServiceProtocol.swift b/Course/Course/Presentation/Video/PlayerServiceProtocol.swift new file mode 100644 index 000000000..3619de512 --- /dev/null +++ b/Course/Course/Presentation/Video/PlayerServiceProtocol.swift @@ -0,0 +1,63 @@ +// +// PlayerServiceProtocol.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import SwiftUI + +public protocol PlayerServiceProtocol { + var router: CourseRouter { get } + + init(courseID: String, blockID: String, interactor: CourseInteractorProtocol, router: CourseRouter) + func blockCompletionRequest() async throws + func presentAppReview() + func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) + func getSubtitles(url: String, selectedLanguage: String) async throws -> [Subtitle] +} + +public class PlayerService: PlayerServiceProtocol { + private let courseID: String + private let blockID: String + private let interactor: CourseInteractorProtocol + public let router: CourseRouter + + public required init( + courseID: String, + blockID: String, + interactor: CourseInteractorProtocol, + router: CourseRouter + ) { + self.courseID = courseID + self.blockID = blockID + self.interactor = interactor + self.router = router + } + + @MainActor + public func blockCompletionRequest() async throws { + try await interactor.blockCompletionRequest(courseID: courseID, blockID: blockID) + NotificationCenter.default.post( + name: NSNotification.blockCompletion, + object: nil + ) + } + + @MainActor + public func presentAppReview() { + router.presentAppReview() + } + + @MainActor + public func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { + router.presentView(transitionStyle: transitionStyle, animated: animated, content: content) + } + + public func getSubtitles(url: String, selectedLanguage: String) async throws -> [Subtitle] { + try await interactor.getSubtitles( + url: url, + selectedLanguage: selectedLanguage + ) + } +} diff --git a/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift b/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift new file mode 100644 index 000000000..4487bab29 --- /dev/null +++ b/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift @@ -0,0 +1,318 @@ +// +// PlayerTrackerProtocol.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import AVKit +import Combine +import Foundation + +public protocol PlayerTrackerProtocol { + associatedtype Player + var player: Player? { get } + var duration: Double { get } + var progress: Double { get } + var isPlaying: Bool { get } + var isReady: Bool { get } + init(url: URL?) + + func getTimePublisher() -> AnyPublisher + func getRatePublisher() -> AnyPublisher + func getFinishPublisher() -> AnyPublisher + func getReadyPublisher() -> AnyPublisher +} + +#if DEBUG +class PlayerTrackerProtocolMock: PlayerTrackerProtocol { + let player: AVPlayer? + var duration: Double { + 1 + } + var progress: Double { + 0 + } + let isPlaying = false + let isReady = false + private let timePublisher: CurrentValueSubject + private let ratePublisher: CurrentValueSubject + private let finishPublisher: PassthroughSubject + private let readyPublisher: PassthroughSubject + + required init(url: URL?) { + var item: AVPlayerItem? + if let url = url { + item = AVPlayerItem(url: url) + } + self.player = AVPlayer(playerItem: item) + timePublisher = CurrentValueSubject(0) + ratePublisher = CurrentValueSubject(0) + finishPublisher = PassthroughSubject() + readyPublisher = PassthroughSubject() + } + + func getTimePublisher() -> AnyPublisher { + timePublisher.eraseToAnyPublisher() + } + + func getRatePublisher() -> AnyPublisher { + ratePublisher.eraseToAnyPublisher() + } + + func getFinishPublisher() -> AnyPublisher { + finishPublisher.eraseToAnyPublisher() + } + + func getReadyPublisher() -> AnyPublisher { + readyPublisher.eraseToAnyPublisher() + } + + func sendProgress(_ progress: Double) { + timePublisher.send(progress) + } + + func sendFinish() { + finishPublisher.send() + } +} +#endif +// MARK: Video +public class PlayerTracker: PlayerTrackerProtocol { + public var isReady: Bool = false + public let player: AVPlayer? + public var duration: Double { + player?.currentItem?.duration.seconds ?? .nan + } + public var isPlaying: Bool { + (player?.rate ?? 0) > 0 + } + + public var progress: Double { + let currentTime = player?.currentTime().seconds ?? 0 + guard !currentTime.isNaN && !currentTime.isInfinite && duration.isNormal + else { + return 0 + } + + return currentTime/duration + } + + private var cancellations: [AnyCancellable] = [] + private var timeObserver: Any? + private let timePublisher: CurrentValueSubject + private let ratePublisher: CurrentValueSubject + private let finishPublisher: PassthroughSubject + private let readyPublisher: PassthroughSubject + + public required init(url: URL?) { + var item: AVPlayerItem? + if let url = url { + item = AVPlayerItem(url: url) + } + self.player = AVPlayer(playerItem: item) + timePublisher = CurrentValueSubject(player?.currentTime().seconds ?? 0) + ratePublisher = CurrentValueSubject(player?.rate ?? 0) + finishPublisher = PassthroughSubject() + readyPublisher = PassthroughSubject() + observe() + } + + deinit { + clear() + } + + private func observe() { + let interval = CMTime( + seconds: 0.1, + preferredTimescale: CMTimeScale(NSEC_PER_SEC) + ) + + timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) {[weak self] time in + self?.timePublisher.send(time.seconds) + } + + player?.publisher(for: \.rate) + .sink {[weak self] rate in + self?.ratePublisher.send(rate) + } + .store(in: &cancellations) + + player?.publisher(for: \.status) + .sink {[weak self] status in + guard let strongSelf = self else { return } + strongSelf.isReady = status == .readyToPlay + strongSelf.readyPublisher.send(strongSelf.isReady) + } + .store(in: &cancellations) + + NotificationCenter.default.publisher( + for: AVPlayerItem.didPlayToEndTimeNotification, + object: player?.currentItem + ) + .sink {[weak self] _ in + if self?.player?.currentItem != nil { + self?.finishPublisher.send() + } + } + .store(in: &cancellations) + } + + private func clear() { + if let observer = timeObserver { + player?.removeTimeObserver(observer) + } + cancellations.removeAll() + } + + public func getTimePublisher() -> AnyPublisher { + timePublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getRatePublisher() -> AnyPublisher { + ratePublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getFinishPublisher() -> AnyPublisher { + finishPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getReadyPublisher() -> AnyPublisher { + readyPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} + +// MARK: YouTube +import YouTubePlayerKit +public class YoutubePlayerTracker: PlayerTrackerProtocol { + public var isReady: Bool = false + + public let player: YouTubePlayer? + public var duration: Double = 0 + public var isPlaying: Bool { + player?.isPlaying ?? false + } + + public var progress: Double { + timePublisher.value / duration + } + + private var cancellations: [AnyCancellable] = [] + private let timePublisher: CurrentValueSubject + private let ratePublisher: CurrentValueSubject + private let finishPublisher: PassthroughSubject + private let readyPublisher: PassthroughSubject + + public required init(url: URL?) { + if let url = url { + let videoID = url.absoluteString.replacingOccurrences(of: "https://www.youtube.com/watch?v=", with: "") + let configuration = YouTubePlayer.Configuration(configure: { + $0.playInline = true + $0.showFullscreenButton = true + $0.allowsPictureInPictureMediaPlayback = false + $0.showControls = true + $0.useModestBranding = false + $0.progressBarColor = .white + $0.showRelatedVideos = false + $0.showCaptions = false + $0.showAnnotations = false + $0.customUserAgent = """ + Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; ja-jp) + AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5 + """ + }) + self.player = YouTubePlayer(source: .video(id: videoID), configuration: configuration) + self.player?.pause() + } else { + self.player = nil + } + + timePublisher = CurrentValueSubject(0) + ratePublisher = CurrentValueSubject(0) + finishPublisher = PassthroughSubject() + readyPublisher = PassthroughSubject() + observe() + } + + deinit { + clear() + } + + private func observe() { + player?.durationPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] duration in + self?.duration = duration.value + } + .store(in: &cancellations) + + player?.currentTimePublisher(updateInterval: 0.1) + .sink { [weak self] time in + self?.timePublisher.send(time.value) + } + .store(in: &cancellations) + player?.statePublisher + .sink { [weak self] state in + switch state { + case .ready: + self?.isReady = true + self?.readyPublisher.send(true) + default: + self?.isReady = false + self?.readyPublisher.send(false) + } + } + .store(in: &cancellations) + + player?.playbackStatePublisher + .sink { [weak self] state in + guard let strongSelf = self else { return } + switch state { + case .playing: + strongSelf.ratePublisher.send(1) + case .ended: + strongSelf.ratePublisher.send(0) + strongSelf.finishPublisher.send() + default: + strongSelf.ratePublisher.send(0) + } + } + .store(in: &cancellations) + } + + private func clear() { + cancellations.removeAll() + } + + public func getTimePublisher() -> AnyPublisher { + timePublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getRatePublisher() -> AnyPublisher { + ratePublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getFinishPublisher() -> AnyPublisher { + finishPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getReadyPublisher() -> AnyPublisher { + readyPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index 0bb477635..573a1195e 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -11,131 +11,17 @@ import SwiftUI import _AVKit_SwiftUI struct PlayerViewController: UIViewControllerRepresentable { - - var videoURL: URL? - var videoResolution: CGSize - var playerHolder: PlayerViewControllerHolder - var progress: ((Float) -> Void) - var seconds: ((Double) -> Void) - - init( - videoURL: URL?, - playerHolder: PlayerViewControllerHolder, - bitrate: CGSize, - progress: @escaping ((Float) -> Void), - seconds: @escaping ((Double) -> Void) - ) { - self.videoURL = videoURL - self.playerHolder = playerHolder - self.videoResolution = bitrate - self.progress = progress - self.seconds = seconds - } - + var playerController: AVPlayerViewController + func makeUIViewController(context: Context) -> AVPlayerViewController { - context.coordinator.currentHolder = playerHolder - if playerHolder.isPlayingInPip { - return playerHolder.playerController - } - - let controller = playerHolder.playerController - controller.modalPresentationStyle = .fullScreen - controller.allowsPictureInPicturePlayback = true - controller.canStartPictureInPictureAutomaticallyFromInline = true - let player = AVPlayer() - controller.player = player - context.coordinator.setPlayer(player) { progress, seconds in - self.progress(progress) - self.seconds(seconds) - } - do { try AVAudioSession.sharedInstance().setCategory(.playback) } catch { print(error.localizedDescription) } - return controller - } - - func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) { - let asset = playerController.player?.currentItem?.asset as? AVURLAsset - if asset?.url.absoluteString != videoURL?.absoluteString && !playerHolder.isPlayingInPip { - let player = context.coordinator.player(from: playerController) - player?.replaceCurrentItem(with: AVPlayerItem(url: videoURL!)) - player?.currentItem?.preferredMaximumResolution = videoResolution - - context.coordinator.setPlayer(player) { progress, seconds in - self.progress(progress) - self.seconds(seconds) - } - } - } - - func makeCoordinator() -> Coordinator { - Coordinator() + return playerController } - static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) { - coordinator.setPlayer(nil) { _, _ in } - } - - class Coordinator { - var currentPlayer: AVPlayer? - var observer: Any? - var cancellations: [AnyCancellable] = [] - weak var currentHolder: PlayerViewControllerHolder? - - func player(from playerController: AVPlayerViewController) -> AVPlayer? { - var player = playerController.player - if player == nil { - player = AVPlayer() - player?.allowsExternalPlayback = true - playerController.player = player - } - return player - } - - func setPlayer(_ player: AVPlayer?, currentProgress: @escaping ((Float, Double) -> Void)) { - cancellations.removeAll() - if let observer = observer { - currentPlayer?.removeTimeObserver(observer) - if currentHolder?.isPlayingInPip == false { - currentPlayer?.pause() - } - } - - let interval = CMTime( - seconds: 0.1, - preferredTimescale: CMTimeScale(NSEC_PER_SEC) - ) - - observer = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) {[weak player] time in - var progress: Float = .zero - let currentSeconds = CMTimeGetSeconds(time) - guard let duration = player?.currentItem?.duration else { return } - let totalSeconds = CMTimeGetSeconds(duration) - progress = Float(currentSeconds / totalSeconds) - currentProgress(progress, currentSeconds) - } - - player?.publisher(for: \.rate) - .sink {[weak self] rate in - guard rate > 0 else { return } - self?.currentHolder?.pausePipIfNeed() - } - .store(in: &cancellations) - currentHolder?.pipRatePublisher()? - .sink {[weak self] rate in - guard rate > 0 else { return } - if self?.currentHolder?.isPlayingInPip == false { - self?.currentPlayer?.pause() - } - } - .store(in: &cancellations) - - currentPlayer = player - - } - } + func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {} } diff --git a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift index 3ba64b192..fac9ef02a 100644 --- a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift +++ b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift @@ -7,125 +7,212 @@ import AVKit import Combine -import Swinject -public protocol PipManagerProtocol { - var isPipActive: Bool { get } - - func holder(for url: URL?, blockID: String, courseID: String, selectedCourseTab: Int) -> PlayerViewControllerHolder? - func set(holder: PlayerViewControllerHolder) - func remove(holder: PlayerViewControllerHolder) - func restore(holder: PlayerViewControllerHolder) async throws - func pipRatePublisher() -> AnyPublisher? - func pauseCurrentPipVideo() -} - -#if DEBUG -public class PipManagerProtocolMock: PipManagerProtocol { - public var isPipActive: Bool { - false - } +public protocol PlayerViewControllerHolderProtocol: AnyObject { + var url: URL? { get } + var blockID: String { get } + var courseID: String { get } + var selectedCourseTab: Int { get } + var playerController: PlayerControllerProtocol? { get } + var isPlaying: Bool { get } + var isPlayingInPip: Bool { get } + var isOtherPlayerInPipPlaying: Bool { get } - public init() {} - public func holder( - for url: URL?, + init( + url: URL?, blockID: String, courseID: String, - selectedCourseTab: Int - ) -> PlayerViewControllerHolder? { - return nil - } - public func set(holder: PlayerViewControllerHolder) {} - public func remove(holder: PlayerViewControllerHolder) {} - public func restore(holder: PlayerViewControllerHolder) async throws {} - public func pipRatePublisher() -> AnyPublisher? { nil } - public func pauseCurrentPipVideo() {} + selectedCourseTab: Int, + videoResolution: CGSize, + pipManager: PipManagerProtocol, + playerTracker: any PlayerTrackerProtocol, + playerDelegate: PlayerDelegateProtocol?, + playerService: PlayerServiceProtocol + ) + func getTimePublisher() -> AnyPublisher + func getErrorPublisher() -> AnyPublisher + func getRatePublisher() -> AnyPublisher + func getReadyPublisher() -> AnyPublisher + func getService() -> PlayerServiceProtocol + func sendCompletion() async } -#endif -public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegate { +public class PlayerViewControllerHolder: PlayerViewControllerHolderProtocol { public let url: URL? public let blockID: String public let courseID: String public let selectedCourseTab: Int - public var isPlayingInPip: Bool = false - public var isOtherPlayerInPip: Bool { + + public var isPlaying: Bool { + playerTracker.isPlaying + } + public var timePublisher: AnyPublisher { + playerTracker.getTimePublisher() + } + + public var isPlayingInPip: Bool { + playerDelegate?.isPlayingInPip ?? false + } + + public var isOtherPlayerInPipPlaying: Bool { let holder = pipManager.holder( for: url, blockID: blockID, courseID: courseID, selectedCourseTab: selectedCourseTab ) - return holder == nil && pipManager.isPipActive + return holder == nil && pipManager.isPipActive && pipManager.isPipPlaying } - - private let pipManager: PipManagerProtocol - - public lazy var playerController: AVPlayerViewController = { + public var duration: Double { + playerTracker.duration + } + private let playerTracker: any PlayerTrackerProtocol + private let playerDelegate: PlayerDelegateProtocol? + private let playerService: PlayerServiceProtocol + private let videoResolution: CGSize + private let errorPublisher = PassthroughSubject() + private var isViewedOnce: Bool = false + private var cancellations: [AnyCancellable] = [] + + let pipManager: PipManagerProtocol + + public lazy var playerController: PlayerControllerProtocol? = { let playerController = AVPlayerViewController() - playerController.delegate = self + playerController.modalPresentationStyle = .fullScreen + playerController.allowsPictureInPicturePlayback = true + playerController.canStartPictureInPictureAutomaticallyFromInline = true + playerController.delegate = playerDelegate + playerController.player = playerTracker.player as? AVPlayer + playerController.player?.currentItem?.preferredMaximumResolution = videoResolution return playerController }() - - public init( + + required public init( url: URL?, blockID: String, courseID: String, - selectedCourseTab: Int + selectedCourseTab: Int, + videoResolution: CGSize, + pipManager: PipManagerProtocol, + playerTracker: any PlayerTrackerProtocol, + playerDelegate: PlayerDelegateProtocol?, + playerService: PlayerServiceProtocol ) { self.url = url self.blockID = blockID self.courseID = courseID self.selectedCourseTab = selectedCourseTab - self.pipManager = Container.shared.resolve(PipManagerProtocol.self)! + self.videoResolution = videoResolution + self.pipManager = pipManager + self.playerTracker = playerTracker + self.playerDelegate = playerDelegate + self.playerService = playerService + addObservers() } - public func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { - isPlayingInPip = true - pipManager.set(holder: self) + private func addObservers() { + timePublisher + .sink {[weak self] _ in + guard let strongSelf = self else { return } + if strongSelf.playerTracker.progress > 0.8 && !strongSelf.isViewedOnce { + strongSelf.isViewedOnce = true + Task { + await strongSelf.sendCompletion() + } + } + } + .store(in: &cancellations) + playerTracker.getFinishPublisher() + .sink { [weak self] in + self?.playerService.presentAppReview() + } + .store(in: &cancellations) + playerTracker.getRatePublisher() + .sink {[weak self] rate in + guard rate > 0 else { return } + self?.pausePipIfNeed() + } + .store(in: &cancellations) + pipManager.pipRatePublisher()? + .sink {[weak self] rate in + guard rate > 0, self?.isPlayingInPip == false else { return } + self?.playerController?.pause() + } + .store(in: &cancellations) + } + + public func pausePipIfNeed() { + if !isPlayingInPip { + pipManager.pauseCurrentPipVideo() + } } - public func playerViewController( - _ playerViewController: AVPlayerViewController, - failedToStartPictureInPictureWithError error: any Error - ) { - isPlayingInPip = false - pipManager.remove(holder: self) + public func getTimePublisher() -> AnyPublisher { + playerTracker.getTimePublisher() + } + + public func getErrorPublisher() -> AnyPublisher { + errorPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() } - public func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { - isPlayingInPip = false - pipManager.remove(holder: self) + public func getRatePublisher() -> AnyPublisher { + playerTracker.getRatePublisher() } - public func playerViewControllerRestoreUserInterfaceForPictureInPictureStop( - _ playerViewController: AVPlayerViewController - ) async -> Bool { + public func getReadyPublisher() -> AnyPublisher { + playerTracker.getReadyPublisher() + } + + public func getService() -> PlayerServiceProtocol { + playerService + } + + public func sendCompletion() async { do { - try await Container.shared.resolve(PipManagerProtocol.self)?.restore(holder: self) - return true + try await playerService.blockCompletionRequest() } catch { - return false + errorPublisher.send(error) } } +} + +extension PlayerViewControllerHolder { + static var mock: PlayerViewControllerHolder { + PlayerViewControllerHolder( + url: URL(string: "")!, + blockID: "", + courseID: "", + selectedCourseTab: 0, + videoResolution: .zero, + pipManager: PipManagerProtocolMock(), + playerTracker: PlayerTrackerProtocolMock(url: URL(string: "")), + playerDelegate: nil, + playerService: PlayerService( + courseID: "", + blockID: "", + interactor: CourseInteractor.mock, + router: CourseRouterMock() + ) + ) + } +} + +extension AVPlayerViewController: PlayerControllerProtocol { + public func play() { + player?.play() + } - public override func isEqual(_ object: Any?) -> Bool { - guard let object = object as? PlayerViewControllerHolder else { - return false - } - return url?.absoluteString == object.url?.absoluteString && - courseID == object.courseID && - blockID == object.blockID && - selectedCourseTab == object.selectedCourseTab + public func pause() { + player?.pause() } - public func pausePipIfNeed() { - if !isPlayingInPip { - pipManager.pauseCurrentPipVideo() - } + public func seekTo(to date: Date) { + player?.seek(to: date) } - public func pipRatePublisher() -> AnyPublisher? { - pipManager.pipRatePublisher() + public func stop() { + player?.replaceCurrentItem(with: nil) } } diff --git a/Course/Course/Presentation/Video/SubtittlesView.swift b/Course/Course/Presentation/Video/SubtitlesView.swift similarity index 93% rename from Course/Course/Presentation/Video/SubtittlesView.swift rename to Course/Course/Presentation/Video/SubtitlesView.swift index f2a1bf81d..97dfe48ad 100644 --- a/Course/Course/Presentation/Video/SubtittlesView.swift +++ b/Course/Course/Presentation/Video/SubtitlesView.swift @@ -1,5 +1,5 @@ // -// SubtittlesView.swift +// SubtitlesView.swift // Course // // Created by  Stepanok Ivan on 04.04.2023. @@ -15,7 +15,7 @@ public struct Subtitle { var text: String } -public struct SubtittlesView: View { +public struct SubtitlesView: View { @Environment (\.isHorizontal) private var isHorizontal @@ -113,20 +113,19 @@ public struct SubtittlesView: View { } #if DEBUG +import Combine struct SubtittlesView_Previews: PreviewProvider { static var previews: some View { - SubtittlesView( + SubtitlesView( languages: [SubtitleUrl(language: "fr", url: "url"), SubtitleUrl(language: "uk", url: "url2")], currentTime: .constant(0), viewModel: VideoPlayerViewModel( - blockID: "", courseID: "", languages: [], - interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), - appStorage: CoreStorageMock(), - connectivity: Connectivity() + playerStateSubject: CurrentValueSubject(nil), + connectivity: Connectivity(), + playerHolder: PlayerViewControllerHolder.mock ), scrollTo: {_ in } ) } diff --git a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift index 27b214068..8e95a31f0 100644 --- a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift @@ -8,16 +8,14 @@ import Foundation import Core import _AVKit_SwiftUI +import Combine public class VideoPlayerViewModel: ObservableObject { - - private var blockID: String - private var courseID: String + @Published var pause: Bool = false + @Published var currentTime: Double = 0 + @Published var isLoading: Bool = true - private let interactor: CourseInteractorProtocol public let connectivity: ConnectivityProtocol - public let router: CourseRouter - public let appStorage: CoreStorage private var subtitlesDownloaded: Bool = false @Published var subtitles: [Subtitle] = [] @@ -31,50 +29,78 @@ public class VideoPlayerViewModel: ObservableObject { showError = errorMessage != nil } } + var isPlayingInPip: Bool { + playerHolder.isPlayingInPip + } + + var isOtherPlayerInPip: Bool { + playerHolder.isOtherPlayerInPipPlaying + } + public let playerHolder: PlayerViewControllerHolderProtocol + internal var subscription = Set() public init( - blockID: String, - courseID: String, languages: [SubtitleUrl], - interactor: CourseInteractorProtocol, - router: CourseRouter, - appStorage: CoreStorage, - connectivity: ConnectivityProtocol + playerStateSubject: CurrentValueSubject? = nil, + connectivity: ConnectivityProtocol, + playerHolder: PlayerViewControllerHolderProtocol ) { - self.blockID = blockID - self.courseID = courseID self.languages = languages - self.interactor = interactor - self.router = router - self.appStorage = appStorage self.connectivity = connectivity + self.playerHolder = playerHolder self.prepareLanguages() + + observePlayer(with: playerStateSubject) } - @MainActor - func blockCompletionRequest() async { - do { - try await interactor.blockCompletionRequest(courseID: courseID, blockID: blockID) - NotificationCenter.default.post( - name: NSNotification.blockCompletion, - object: nil - ) - } catch let error { - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError + func observePlayer(with playerStateSubject: CurrentValueSubject?) { + playerStateSubject?.sink { [weak self] state in + switch state { + case .pause: + if self?.playerHolder.isPlayingInPip != true { + self?.playerHolder.playerController?.pause() + } + case .kill: + if self?.playerHolder.isPlayingInPip != true { + self?.playerHolder.playerController?.stop() + } + case .none: + break } } + .store(in: &subscription) + + playerHolder.getTimePublisher() + .sink {[weak self] time in + self?.currentTime = time + } + .store(in: &subscription) + playerHolder.getErrorPublisher() + .sink {[weak self] error in + if error.isInternetError || error is NoCachedDataError { + self?.errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + self?.errorMessage = CoreLocalization.Error.unknownError + } + } + .store(in: &subscription) + playerHolder.getReadyPublisher() + .sink {[weak self] isReady in + guard isReady else { return } + self?.isLoading = false + } + .store(in: &subscription) + } @MainActor public func getSubtitles(subtitlesUrl: String) async { do { - let result = try await interactor.getSubtitles( + let result = try await playerHolder.getService().getSubtitles( url: subtitlesUrl, selectedLanguage: self.selectedLanguage ?? "en" ) + subtitles = result } catch { print(">>>>> ⛔️⛔️⛔️⛔️⛔️⛔️⛔️⛔️", error) @@ -94,6 +120,13 @@ public class VideoPlayerViewModel: ObservableObject { return locale.localizedString(forLanguageCode: code)?.capitalized ?? "" } + func pauseScrolling() { + pause = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.pause = false + } + } + private func generateLanguageItems() { items = languages.map { language in let name = generateLanguageName(code: language.language) @@ -133,7 +166,9 @@ public class VideoPlayerViewModel: ObservableObject { } func presentPicker() { - router.presentView( + let service = playerHolder.getService() + let router = service.router + service.presentView( transitionStyle: .crossDissolve, animated: true ) { diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index 1ee73812a..2374a4f14 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -56,7 +56,7 @@ public struct YouTubeVideoPlayer: View { } } ZStack { - SubtittlesView( + SubtitlesView( languages: viewModel.languages, currentTime: $viewModel.currentTime, viewModel: viewModel, @@ -86,16 +86,10 @@ struct YouTubeVideoPlayer_Previews: PreviewProvider { static var previews: some View { YouTubeVideoPlayer( viewModel: YouTubeVideoPlayerViewModel( - url: "", - blockID: "", - courseID: "", languages: [], playerStateSubject: CurrentValueSubject(nil), - interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), - appStorage: CoreStorageMock(), connectivity: Connectivity(), - pipManager: PipManagerProtocolMock() + playerHolder: YoutubePlayerViewControllerHolder.mock ), isOnScreen: true ) diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift index 077a1e0e3..acaacde23 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -13,138 +13,7 @@ import Swinject public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { - @Published var youtubePlayer: YouTubePlayer - private (set) var play = false - @Published var isLoading: Bool = true - @Published var currentTime: Double = 0 - @Published var pause: Bool = false - - private var subscription = Set() - private var duration: Double? - private var isViewedOnce: Bool = false - private var url: String - private let pipManager: PipManagerProtocol - - public init( - url: String, - blockID: String, - courseID: String, - languages: [SubtitleUrl], - playerStateSubject: CurrentValueSubject, - interactor: CourseInteractorProtocol, - router: CourseRouter, - appStorage: CoreStorage, - connectivity: ConnectivityProtocol, - pipManager: PipManagerProtocol - ) { - self.url = url - - let videoID = url.replacingOccurrences(of: "https://www.youtube.com/watch?v=", with: "") - let configuration = YouTubePlayer.Configuration(configure: { - $0.autoPlay = !pipManager.isPipActive - $0.playInline = true - $0.showFullscreenButton = true - $0.allowsPictureInPictureMediaPlayback = false - $0.showControls = true - $0.useModestBranding = false - $0.progressBarColor = .white - $0.showRelatedVideos = false - $0.showCaptions = false - $0.showAnnotations = false - $0.customUserAgent = """ - Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; ja-jp) - AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5 - """ - }) - self.youtubePlayer = YouTubePlayer(source: .video(id: videoID), configuration: configuration) - self.pipManager = pipManager - super.init( - blockID: blockID, - courseID: courseID, - languages: languages, - interactor: interactor, - router: router, - appStorage: appStorage, - connectivity: connectivity - ) - - self.youtubePlayer.pause() - - subscrube(playerStateSubject: playerStateSubject) - } - - func pauseScrolling() { - pause = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.pause = false - } - } - - private func subscrube(playerStateSubject: CurrentValueSubject) { - playerStateSubject.sink(receiveValue: { [weak self] state in - switch state { - case .pause: - self?.youtubePlayer.stop() - case .kill, .none: - break - } - }).store(in: &subscription) - - youtubePlayer.durationPublisher.sink(receiveValue: { [weak self] duration in - self?.duration = duration.value - }).store(in: &subscription) - - youtubePlayer.currentTimePublisher(updateInterval: 0.1).sink(receiveValue: { [weak self] time in - guard let self else { return } - if !self.pause { - self.currentTime = time.value - } - - if let duration = self.duration { - if (time.value / duration) >= 0.8 { - if !isViewedOnce { - Task { - await self.blockCompletionRequest() - - } - isViewedOnce = true - } - } - if (time.value / duration) >= 0.999 { - self.router.presentAppReview() - } - } - }).store(in: &subscription) - - youtubePlayer.playbackStatePublisher.sink(receiveValue: { [weak self] state in - guard let self else { return } - switch state { - case .unstarted: - self.play = false - case .ended: - self.play = false - case .playing: - self.play = true - self.pipManager.pauseCurrentPipVideo() - case .paused: - self.play = false - case .buffering, .cued: - break - } - }).store(in: &subscription) - - youtubePlayer.statePublisher.sink(receiveValue: { [weak self] state in - guard let self else { return } - if state == .ready { - self.isLoading = false - } - }).store(in: &subscription) - - pipManager.pipRatePublisher()? - .sink {[weak self] rate in - guard rate > 0 else { return } - self?.youtubePlayer.pause() - } - .store(in: &subscription) + var youtubePlayer: YouTubePlayer { + (playerHolder.playerController as? YouTubePlayer) ?? YouTubePlayer() } } diff --git a/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift new file mode 100644 index 000000000..e74545c25 --- /dev/null +++ b/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift @@ -0,0 +1,183 @@ +// +// YoutubePlayerViewControllerHolder.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import Combine +import Foundation +import YouTubePlayerKit + +public class YoutubePlayerViewControllerHolder: PlayerViewControllerHolderProtocol { + public let url: URL? + public let blockID: String + public let courseID: String + public let selectedCourseTab: Int + + public var isPlaying: Bool { + playerTracker.isPlaying + } + public var timePublisher: AnyPublisher { + playerTracker.getTimePublisher() + } + + public let isPlayingInPip: Bool = false + + public var isOtherPlayerInPipPlaying: Bool { + pipManager.isPipActive && pipManager.isPipPlaying + } + + public var duration: Double { + playerTracker.duration + } + private let playerTracker: any PlayerTrackerProtocol + private let playerService: PlayerServiceProtocol + private let videoResolution: CGSize + private let errorPublisher = PassthroughSubject() + private var isViewedOnce: Bool = false + private var cancellations: [AnyCancellable] = [] + + let pipManager: PipManagerProtocol + + public var playerController: PlayerControllerProtocol? { + playerTracker.player as? YouTubePlayer + } + + required public init( + url: URL?, + blockID: String, + courseID: String, + selectedCourseTab: Int, + videoResolution: CGSize, + pipManager: PipManagerProtocol, + playerTracker: any PlayerTrackerProtocol, + playerDelegate: PlayerDelegateProtocol?, + playerService: PlayerServiceProtocol + ) { + self.url = url + self.blockID = blockID + self.courseID = courseID + self.selectedCourseTab = selectedCourseTab + self.videoResolution = videoResolution + self.pipManager = pipManager + self.playerTracker = playerTracker + self.playerService = playerService + let youtubePlayer = playerTracker.player as? YouTubePlayer + var configuration = youtubePlayer?.configuration + configuration?.autoPlay = !pipManager.isPipActive + if let configuration = configuration { + youtubePlayer?.update(configuration: configuration) + } + addObservers() + } + + private func addObservers() { + timePublisher + .sink {[weak self] _ in + guard let strongSelf = self else { return } + if strongSelf.playerTracker.progress > 0.8 && !strongSelf.isViewedOnce { + strongSelf.isViewedOnce = true + Task { + await strongSelf.sendCompletion() + } + } + } + .store(in: &cancellations) + playerTracker.getFinishPublisher() + .sink { [weak self] in + self?.playerService.presentAppReview() + } + .store(in: &cancellations) + playerTracker.getRatePublisher() + .sink {[weak self] rate in + guard rate > 0 else { return } + self?.pausePipIfNeed() + } + .store(in: &cancellations) + pipManager.pipRatePublisher()? + .sink {[weak self] rate in + guard rate > 0, self?.isPlayingInPip == false else { return } + self?.playerController?.pause() + } + .store(in: &cancellations) + } + + public func pausePipIfNeed() { + if !isPlayingInPip { + pipManager.pauseCurrentPipVideo() + } + } + + public func getTimePublisher() -> AnyPublisher { + playerTracker.getTimePublisher() + } + + public func getErrorPublisher() -> AnyPublisher { + errorPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getRatePublisher() -> AnyPublisher { + playerTracker.getRatePublisher() + } + + public func getReadyPublisher() -> AnyPublisher { + playerTracker.getReadyPublisher() + } + + public func getService() -> PlayerServiceProtocol { + playerService + } + + public func sendCompletion() async { + do { + try await playerService.blockCompletionRequest() + } catch { + errorPublisher.send(error) + } + } +} + +extension YoutubePlayerViewControllerHolder { + static var mock: YoutubePlayerViewControllerHolder { + YoutubePlayerViewControllerHolder( + url: URL(string: "")!, + blockID: "", + courseID: "", + selectedCourseTab: 0, + videoResolution: .zero, + pipManager: PipManagerProtocolMock(), + playerTracker: PlayerTrackerProtocolMock(url: URL(string: "")), + playerDelegate: nil, + playerService: PlayerService( + courseID: "", + blockID: "", + interactor: CourseInteractor.mock, + router: CourseRouterMock() + ) + ) + } +} + +extension YouTubePlayer: PlayerControllerProtocol { + public func play() { + self.play(completion: nil) + } + + public func pause() { + self.pause(completion: nil) + } + + public func seekTo(to date: Date) { + self.seek( + to: Measurement(value: date.secondsSinceMidnight(), unit: UnitDuration.seconds), + allowSeekAhead: true + ) + } + + public func stop() { + self.stop(completion: nil) + } +} diff --git a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift index 2a6b2f722..a083fa577 100644 --- a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift @@ -33,13 +33,10 @@ final class VideoPlayerViewModelTests: XCTestCase { Given(interactor, .getSubtitles(url: .any, selectedLanguage: .any, willReturn: subtitles)) - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) + let viewModel = VideoPlayerViewModel(languages: [], connectivity: connectivity, playerHolder: playerHolder) await viewModel.getSubtitles(subtitlesUrl: "url") @@ -60,13 +57,10 @@ final class VideoPlayerViewModelTests: XCTestCase { Given(connectivity, .isInternetAvaliable(getter: false)) Given(interactor, .getSubtitles(url: .any, selectedLanguage: .any, willReturn: subtitles)) - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) + let viewModel = VideoPlayerViewModel(languages: [], connectivity: connectivity, playerHolder: playerHolder) await viewModel.getSubtitles(subtitlesUrl: "url") @@ -82,14 +76,11 @@ final class VideoPlayerViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) - + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) + let viewModel = VideoPlayerViewModel(languages: [], connectivity: connectivity, playerHolder: playerHolder) + viewModel.languages = [ SubtitleUrl(language: "en", url: "url"), SubtitleUrl(language: "uk", url: "url2") @@ -110,17 +101,13 @@ final class VideoPlayerViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willProduce: {_ in})) - await viewModel.blockCompletionRequest() + await playerHolder.sendCompletion() Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) } @@ -130,20 +117,24 @@ final class VideoPlayerViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) + let viewModel = VideoPlayerViewModel(languages: [], connectivity: connectivity, playerHolder: playerHolder) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willThrow: NSError())) - await viewModel.blockCompletionRequest() + await playerHolder.sendCompletion() Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) + let expectation = XCTestExpectation(description: "Wait for combine") + + DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 1) + XCTAssertTrue(viewModel.showError) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) } @@ -155,17 +146,21 @@ final class VideoPlayerViewModelTests: XCTestCase { let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) + let viewModel = VideoPlayerViewModel(languages: [], connectivity: connectivity, playerHolder: playerHolder) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willThrow: noInternetError)) - await viewModel.blockCompletionRequest() + await playerHolder.sendCompletion() + + let expectation = XCTestExpectation(description: "Wait for combine") + + DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 1) Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 131cf674b..19a8768f7 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -14,6 +14,7 @@ import Dashboard import Profile import Course import Discussion +import Combine // swiftlint:disable function_body_length type_body_length class ScreenAssembly: Assembly { @@ -326,38 +327,104 @@ class ScreenAssembly: Assembly { container.register( YouTubeVideoPlayerViewModel.self - ) { r, url, blockID, courseID, languages, playerStateSubject in - YouTubeVideoPlayerViewModel( - url: url, - blockID: blockID, - courseID: courseID, + ) { (r, url: URL?, blockID: String, courseID: String, languages, playerStateSubject) in + let router: Router = r.resolve(Router.self)! + return YouTubeVideoPlayerViewModel( languages: languages, playerStateSubject: playerStateSubject, - interactor: r.resolve(CourseInteractorProtocol.self)!, - router: r.resolve(CourseRouter.self)!, - appStorage: r.resolve(CoreStorage.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, - pipManager: r.resolve(PipManagerProtocol.self)! + playerHolder: r.resolve( + YoutubePlayerViewControllerHolder.self, + arguments: url, + blockID, + courseID, + router.currentCourseTabSelection + )! ) } - container.register( - EncodedVideoPlayerViewModel.self - ) { r, url, blockID, courseID, languages, playerStateSubject in + container.register(EncodedVideoPlayerViewModel.self) { (r, url: URL?, blockID: String, courseID: String, languages: [SubtitleUrl], playerStateSubject: CurrentValueSubject) in let router: Router = r.resolve(Router.self)! + + let holder = r.resolve( + PlayerViewControllerHolder.self, + arguments: url, + blockID, + courseID, + router.currentCourseTabSelection + )! return EncodedVideoPlayerViewModel( - url: url, - blockID: blockID, - courseID: courseID, languages: languages, playerStateSubject: playerStateSubject, - interactor: r.resolve(CourseInteractorProtocol.self)!, - router: r.resolve(CourseRouter.self)!, - appStorage: r.resolve(CoreStorage.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, + playerHolder: holder + ) + } + + container.register(PlayerDelegateProtocol.self) { _, manager in + PlayerDelegate(pipManager: manager) + } + + container.register(YoutubePlayerTracker.self) { (_, url) in + YoutubePlayerTracker(url: url) + } + + container.register(PlayerTracker.self) { (_, url) in + PlayerTracker(url: url) + } + + container.register( + YoutubePlayerViewControllerHolder.self + ) { r, url, blockID, courseID, selectedCourseTab in + YoutubePlayerViewControllerHolder( + url: url, + blockID: blockID, + courseID: courseID, + selectedCourseTab: selectedCourseTab, + videoResolution: .zero, pipManager: r.resolve(PipManagerProtocol.self)!, - selectedCourseTab: router.currentCourseTabSelection + playerTracker: r.resolve(YoutubePlayerTracker.self, argument: url)!, + playerDelegate: nil, + playerService: r.resolve(PlayerServiceProtocol.self, arguments: courseID, blockID)! + ) + } + + container.register( + PlayerViewControllerHolder.self + ) { (r, url: URL?, blockID: String, courseID: String, selectedCourseTab: Int) in + let pipManager = r.resolve(PipManagerProtocol.self)! + if let holder = pipManager.holder( + for: url, + blockID: blockID, + courseID: courseID, + selectedCourseTab: selectedCourseTab + ) as? PlayerViewControllerHolder { + return holder + } + + let storage = r.resolve(CoreStorage.self)! + let quality = storage.userSettings?.streamingQuality ?? .auto + let tracker = r.resolve(PlayerTracker.self, argument: url)! + let delegate = r.resolve(PlayerDelegateProtocol.self, argument: pipManager)! + let holder = PlayerViewControllerHolder( + url: url, + blockID: blockID, + courseID: courseID, + selectedCourseTab: selectedCourseTab, + videoResolution: quality.resolution, + pipManager: pipManager, + playerTracker: tracker, + playerDelegate: delegate, + playerService: r.resolve(PlayerServiceProtocol.self, arguments: courseID, blockID)! ) + delegate.playerHolder = holder + return holder + } + + container.register(PlayerServiceProtocol.self) { r, courseID, blockID in + let interactor = r.resolve(CourseInteractorProtocol.self)! + let router = r.resolve(CourseRouter.self)! + return PlayerService(courseID: courseID, blockID: blockID, interactor: interactor, router: router) } container.register(HandoutsViewModel.self) { r, courseID in diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 8720ae03f..636c4d101 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -11,7 +11,7 @@ import Discovery import SwiftUI public class PipManager: PipManagerProtocol { - var controllerHolder: PlayerViewControllerHolder? + var controllerHolder: PlayerViewControllerHolderProtocol? let discoveryInteractor: DiscoveryInteractorProtocol let courseInteractor: CourseInteractorProtocol let router: Router @@ -19,10 +19,10 @@ public class PipManager: PipManagerProtocol { public var isPipActive: Bool { controllerHolder != nil } - - private var ratePublisher: PassthroughSubject? - private var cancellations: [AnyCancellable] = [] - + public var isPipPlaying: Bool { + controllerHolder?.isPlaying ?? false + } + public init( router: Router, discoveryInteractor: DiscoveryInteractorProtocol, @@ -40,7 +40,7 @@ public class PipManager: PipManagerProtocol { blockID: String, courseID: String, selectedCourseTab: Int - ) -> PlayerViewControllerHolder? { + ) -> PlayerViewControllerHolderProtocol? { if controllerHolder?.blockID == blockID, controllerHolder?.courseID == courseID, controllerHolder?.selectedCourseTab == selectedCourseTab { @@ -50,32 +50,29 @@ public class PipManager: PipManagerProtocol { return nil } - public func set(holder: PlayerViewControllerHolder) { + public func set(holder: PlayerViewControllerHolderProtocol) { controllerHolder = holder - ratePublisher = PassthroughSubject() - cancellations.removeAll() - holder.playerController.player?.publisher(for: \.rate) - .sink { [weak self] rate in - self?.ratePublisher?.send(rate) - } - .store(in: &cancellations) } - public func remove(holder: PlayerViewControllerHolder) { - if controllerHolder == holder { + public func remove(holder: PlayerViewControllerHolderProtocol) { + if isCurrentHolderEqualTo(holder) { controllerHolder = nil - cancellations.removeAll() - ratePublisher = nil } } + + private func isCurrentHolderEqualTo(_ holder: PlayerViewControllerHolderProtocol) -> Bool { + controllerHolder?.blockID == holder.blockID && + controllerHolder?.courseID == holder.courseID && + controllerHolder?.url == holder.url && + controllerHolder?.selectedCourseTab == holder.selectedCourseTab + } public func pipRatePublisher() -> AnyPublisher? { - ratePublisher? - .eraseToAnyPublisher() + controllerHolder?.getRatePublisher() } @MainActor - public func restore(holder: PlayerViewControllerHolder) async throws { + public func restore(holder: PlayerViewControllerHolderProtocol) async throws { let courseID = holder.courseID // if we are on CourseUnitView, and tab is same with holder @@ -94,11 +91,11 @@ public class PipManager: PipManagerProtocol { public func pauseCurrentPipVideo() { guard let holder = controllerHolder else { return } - holder.playerController.player?.pause() + holder.playerController?.pause() } @MainActor - private func navigate(to holder: PlayerViewControllerHolder) async throws { + private func navigate(to holder: PlayerViewControllerHolderProtocol) async throws { let currentControllers = router.getNavigationController().viewControllers guard let mainController = currentControllers.first as? UIHostingController else { return @@ -127,7 +124,7 @@ public class PipManager: PipManagerProtocol { @MainActor private func courseVerticalController( - for holder: PlayerViewControllerHolder + for holder: PlayerViewControllerHolderProtocol ) async throws -> UIHostingController { var courseStructure = try await courseInteractor.getLoadedCourseBlocks(courseID: holder.courseID) if holder.selectedCourseTab == CourseTab.videos.rawValue { @@ -150,7 +147,7 @@ public class PipManager: PipManagerProtocol { @MainActor private func courseUnitController( - for holder: PlayerViewControllerHolder + for holder: PlayerViewControllerHolderProtocol ) async throws -> UIHostingController { var courseStructure = try await courseInteractor.getLoadedCourseBlocks(courseID: holder.courseID) @@ -178,7 +175,7 @@ public class PipManager: PipManagerProtocol { @MainActor private func containerController( - for holder: PlayerViewControllerHolder + for holder: PlayerViewControllerHolderProtocol ) async throws -> UIHostingController { let courseDetails = try await getCourseDetails(for: holder) let isActive: Bool? = nil @@ -195,7 +192,7 @@ public class PipManager: PipManagerProtocol { return controller } - private func getCourseDetails(for holder: PlayerViewControllerHolder) async throws -> CourseDetails { + private func getCourseDetails(for holder: PlayerViewControllerHolderProtocol) async throws -> CourseDetails { if let value = try? await discoveryInteractor.getLoadedCourseDetails( courseID: holder.courseID ) {