From 0b483572984a4c8c8996fef04a5ff78f8871cde0 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Sun, 22 Mar 2020 18:20:32 +0000 Subject: [PATCH 01/18] #396 & #383: Rebuilding the video player No longer do we navigate to a separate screen, but instead we display a full-screen landscape video player from the start. --- Emitron/Emitron.xcodeproj/project.pbxproj | 24 ++- Emitron/Emitron/AppDelegate.swift | 12 -- .../UI/PortraitHostingController.swift | 36 +++++ Emitron/Emitron/UI/SceneDelegate.swift | 2 +- .../ChildContentListingView.swift | 30 ++-- .../Content Detail/ContentDetailView.swift | 29 ++-- .../FullScreenVideoPlayerRepresentable.swift | 41 +++++ .../FullScreenVideoPlayerViewController.swift | 104 +++++++++++++ .../LandscapeAVPlayerViewController.swift | 35 +++++ Emitron/Emitron/UI/Video/VideoView.swift | 140 ------------------ 10 files changed, 268 insertions(+), 185 deletions(-) create mode 100644 Emitron/Emitron/UI/PortraitHostingController.swift create mode 100644 Emitron/Emitron/UI/Video/FullScreenVideoPlayerRepresentable.swift create mode 100644 Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift create mode 100644 Emitron/Emitron/UI/Video/LandscapeAVPlayerViewController.swift delete mode 100644 Emitron/Emitron/UI/Video/VideoView.swift diff --git a/Emitron/Emitron.xcodeproj/project.pbxproj b/Emitron/Emitron.xcodeproj/project.pbxproj index e7e7c4be..3dad6877 100644 --- a/Emitron/Emitron.xcodeproj/project.pbxproj +++ b/Emitron/Emitron.xcodeproj/project.pbxproj @@ -196,6 +196,10 @@ 22C3F125241A2655002812CB /* app-icon--default.dev-ipadpro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 22C3F11F241A2654002812CB /* app-icon--default.dev-ipadpro@2x.png */; }; 22C3F126241A2655002812CB /* app-icon--default.beta-ipadpro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 22C3F120241A2654002812CB /* app-icon--default.beta-ipadpro@2x.png */; }; 22C3F127241A2655002812CB /* app-icon--default.dev-ipad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 22C3F121241A2655002812CB /* app-icon--default.dev-ipad@2x.png */; }; + 22C3F1532427954A002812CB /* LandscapeAVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C3F1522427954A002812CB /* LandscapeAVPlayerViewController.swift */; }; + 22C3F155242795A1002812CB /* PortraitHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C3F154242795A1002812CB /* PortraitHostingController.swift */; }; + 22C3F15724279692002812CB /* FullScreenVideoPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C3F15624279692002812CB /* FullScreenVideoPlayerViewController.swift */; }; + 22C3F159242796EC002812CB /* FullScreenVideoPlayerRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C3F158242796EC002812CB /* FullScreenVideoPlayerRepresentable.swift */; }; 22C4EADB23DB8A5A001A3FDA /* WatchStatsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C4EADA23DB8A5A001A3FDA /* WatchStatsService.swift */; }; 22C4EADD23DB8B33001A3FDA /* WatchStatsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C4EADC23DB8B33001A3FDA /* WatchStatsRequest.swift */; }; 22C4EADF23DC4338001A3FDA /* SyncRequest+WatchStat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C4EADE23DC4338001A3FDA /* SyncRequest+WatchStat.swift */; }; @@ -324,7 +328,6 @@ B6FC15AE22CB529E0078CEDB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FC15AD22CB529E0078CEDB /* SettingsView.swift */; }; B6FC15B122CB53220078CEDB /* TextListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FC15B022CB53220078CEDB /* TextListItemView.swift */; }; B6FC15B522CB53730078CEDB /* ContentDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FC15B422CB53730078CEDB /* ContentDetailView.swift */; }; - B6FC15BB22CB55160078CEDB /* VideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FC15BA22CB55160078CEDB /* VideoView.swift */; }; B6FC15C122CB55C40078CEDB /* JSONAPIResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FC15C022CB55C40078CEDB /* JSONAPIResource.swift */; }; B6FC15C322CB55CC0078CEDB /* JSONAPIRelationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FC15C222CB55CC0078CEDB /* JSONAPIRelationship.swift */; }; B6FC15C522CB55D30078CEDB /* JSONAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FC15C422CB55D30078CEDB /* JSONAPIError.swift */; }; @@ -531,6 +534,10 @@ 22C3F11F241A2654002812CB /* app-icon--default.dev-ipadpro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "app-icon--default.dev-ipadpro@2x.png"; sourceTree = ""; }; 22C3F120241A2654002812CB /* app-icon--default.beta-ipadpro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "app-icon--default.beta-ipadpro@2x.png"; sourceTree = ""; }; 22C3F121241A2655002812CB /* app-icon--default.dev-ipad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "app-icon--default.dev-ipad@2x.png"; sourceTree = ""; }; + 22C3F1522427954A002812CB /* LandscapeAVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapeAVPlayerViewController.swift; sourceTree = ""; }; + 22C3F154242795A1002812CB /* PortraitHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHostingController.swift; sourceTree = ""; }; + 22C3F15624279692002812CB /* FullScreenVideoPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenVideoPlayerViewController.swift; sourceTree = ""; }; + 22C3F158242796EC002812CB /* FullScreenVideoPlayerRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenVideoPlayerRepresentable.swift; sourceTree = ""; }; 22C4EADA23DB8A5A001A3FDA /* WatchStatsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchStatsService.swift; sourceTree = ""; }; 22C4EADC23DB8B33001A3FDA /* WatchStatsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchStatsRequest.swift; sourceTree = ""; }; 22C4EADE23DC4338001A3FDA /* SyncRequest+WatchStat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncRequest+WatchStat.swift"; sourceTree = ""; }; @@ -672,7 +679,6 @@ B6FC15AD22CB529E0078CEDB /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; B6FC15B022CB53220078CEDB /* TextListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextListItemView.swift; sourceTree = ""; }; B6FC15B422CB53730078CEDB /* ContentDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentDetailView.swift; sourceTree = ""; }; - B6FC15BA22CB55160078CEDB /* VideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoView.swift; sourceTree = ""; }; B6FC15C022CB55C40078CEDB /* JSONAPIResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONAPIResource.swift; sourceTree = ""; }; B6FC15C222CB55CC0078CEDB /* JSONAPIRelationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONAPIRelationship.swift; sourceTree = ""; }; B6FC15C422CB55D30078CEDB /* JSONAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONAPIError.swift; sourceTree = ""; }; @@ -1036,7 +1042,9 @@ 229F0AC323BB29230004DD4F /* Video */ = { isa = PBXGroup; children = ( - B6FC15BA22CB55160078CEDB /* VideoView.swift */, + 22C3F1522427954A002812CB /* LandscapeAVPlayerViewController.swift */, + 22C3F15624279692002812CB /* FullScreenVideoPlayerViewController.swift */, + 22C3F158242796EC002812CB /* FullScreenVideoPlayerRepresentable.swift */, ); path = Video; sourceTree = ""; @@ -1524,6 +1532,7 @@ B6D7DC5622C7B4A0006DD325 /* UI */ = { isa = PBXGroup; children = ( + 22C3F154242795A1002812CB /* PortraitHostingController.swift */, B6D7DC3022C79743006DD325 /* SceneDelegate.swift */, B62B9A7C22DF764900122CE8 /* App Root */, B6FC15AA22CB52430078CEDB /* Downloads */, @@ -1660,14 +1669,14 @@ B6FC15AC22CB52570078CEDB /* Shared */ = { isa = PBXGroup; children = ( - 22C6410923FA904500CBFDE5 /* Tags */, - 22C6410423F893CB00CBFDE5 /* Download Icon */, B6C4F3D122E6ECA40087ED10 /* CheckmarkView.swift */, B595654323130E4C00A3FF44 /* CustomToggleView.swift */, B66778AB2305D2D4003EEBAB /* MainButtonView.swift */, 8B283DEE23169A1E001F1B17 /* ProgressBarView.swift */, 229F0AC223BB27820004DD4F /* Content Detail */, 229F0AC123BB27790004DD4F /* Content List */, + 22C6410423F893CB00CBFDE5 /* Download Icon */, + 22C6410923FA904500CBFDE5 /* Tags */, ); path = Shared; sourceTree = ""; @@ -2019,6 +2028,7 @@ 8BFC74C32364A9BE001979F1 /* ContentScreen.swift in Sources */, B6C0F0CF22D5D3EE00012839 /* TabNavView.swift in Sources */, B6DF2F9122CA00820081A3A3 /* Request.swift in Sources */, + 22C3F15724279692002812CB /* FullScreenVideoPlayerViewController.swift in Sources */, B62B9A8022DF76A500122CE8 /* MainView.swift in Sources */, B66778AC2305D2D4003EEBAB /* MainButtonView.swift in Sources */, B6D7DC3122C79743006DD325 /* SceneDelegate.swift in Sources */, @@ -2074,8 +2084,8 @@ 22C640FA23F609B600CBFDE5 /* SearchFieldView.swift in Sources */, 22BE654F23F1F66E00717369 /* Comparable+Clamped.swift in Sources */, B6C4F3D222E6ECA50087ED10 /* CheckmarkView.swift in Sources */, + 22C3F1532427954A002812CB /* LandscapeAVPlayerViewController.swift in Sources */, 22B8265D23AF37FE00D4BA23 /* ProgressionAdapter.swift in Sources */, - B6FC15BB22CB55160078CEDB /* VideoView.swift in Sources */, 222C0AB023DF554E00D65EBD /* SettingsOption.swift in Sources */, 2278AE50240A74C400855221 /* UIApplication+DismissKeyboard.swift in Sources */, B6C0F0DD22D5FA1C00012839 /* ContentDetailModel+Extensions.swift in Sources */, @@ -2101,6 +2111,7 @@ 22C4EAEF23DEF91D001A3FDA /* SettingsKey.swift in Sources */, B6DF2FC022CA861C0081A3A3 /* RandomString.swift in Sources */, B629AB4C22E60BD70037F4D8 /* CourseHeaderView.swift in Sources */, + 22C3F159242796EC002812CB /* FullScreenVideoPlayerRepresentable.swift in Sources */, 22BFE75523D9905500495BA9 /* SnackbarView.swift in Sources */, 223D77DF23B6515A005BE95D /* ContentRepository.swift in Sources */, 2228E40723B999D500E103AA /* CategoryRepository.swift in Sources */, @@ -2163,6 +2174,7 @@ 22B8265923AF109800D4BA23 /* EntityAdapter.swift in Sources */, B6C4F3DF22E710640087ED10 /* PersistenceStore.swift in Sources */, 22C0513423A4FB8E004D1223 /* Bookmark+Persistence.swift in Sources */, + 22C3F155242795A1002812CB /* PortraitHostingController.swift in Sources */, 22B8266A23AF608A00D4BA23 /* DataCacheUpdate.swift in Sources */, B6C4F3CB22E6E44A0087ED10 /* TitleCheckmarkView.swift in Sources */, 2213195623EB7A7800F15816 /* CompletedIconView.swift in Sources */, diff --git a/Emitron/Emitron/AppDelegate.swift b/Emitron/Emitron/AppDelegate.swift index d061d6d4..12c4fc62 100644 --- a/Emitron/Emitron/AppDelegate.swift +++ b/Emitron/Emitron/AppDelegate.swift @@ -100,18 +100,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } - // handle orientation for the device - func application (_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { - guard let viewController = (window?.rootViewController?.presentedViewController) else { - return .portrait - } - if viewController.isKind(of: NSClassFromString("AVFullScreenViewController")!) { - return .allButUpsideDown - } else { - return .portrait - } - } - // For dealing with downloading of videos in the background func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { assert(identifier == DownloadProcessor.sessionIdentifier, "Unknown Background URLSession. Unable to handle these events.") diff --git a/Emitron/Emitron/UI/PortraitHostingController.swift b/Emitron/Emitron/UI/PortraitHostingController.swift new file mode 100644 index 00000000..73bb34a8 --- /dev/null +++ b/Emitron/Emitron/UI/PortraitHostingController.swift @@ -0,0 +1,36 @@ +// Copyright (c) 2020 Razeware LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, +// distribute, sublicense, create a derivative work, and/or sell copies of the +// Software in any work that is designed, intended, or marketed for pedagogical or +// instructional purposes related to programming, coding, application development, +// or information technology. Permission for such use, copying, modification, +// merger, publication, distribution, sublicensing, creation of derivative works, +// or sale is expressly withheld. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import SwiftUI + +class PortraitHostingController: UIHostingController where Content: View { + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + // iPads can have any orientation. Everything else should be portrait only. + UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait + } +} diff --git a/Emitron/Emitron/UI/SceneDelegate.swift b/Emitron/Emitron/UI/SceneDelegate.swift index 107a7b0c..6e40e952 100644 --- a/Emitron/Emitron/UI/SceneDelegate.swift +++ b/Emitron/Emitron/UI/SceneDelegate.swift @@ -71,7 +71,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { .environmentObject(sessionController) .environmentObject(dataManager) - window.rootViewController = UIHostingController(rootView: mainView) + window.rootViewController = PortraitHostingController(rootView: mainView) self.window = window window.rootViewController?.view.backgroundColor = UIColor.backgroundColor // TODO: When a modifier is available this should be refactored diff --git a/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift b/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift index 105c15a6..ad5a1203 100644 --- a/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift +++ b/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift @@ -30,6 +30,7 @@ import SwiftUI struct ChildContentListingView: View { @ObservedObject var childContentsViewModel: ChildContentsViewModel + @Binding var currentlyDisplayedVideoPlaybackViewModel: VideoPlaybackViewModel? @EnvironmentObject var sessionController: SessionController var body: some View { @@ -117,25 +118,20 @@ struct ChildContentListingView: View { } ) } else { - let childVideoPlaybackViewModelProvider: VideoViewModelProvider = { dismissClosure in - childDynamicContentViewModel.videoPlaybackViewModel( + return AnyView(Button(action: { + self.currentlyDisplayedVideoPlaybackViewModel = childDynamicContentViewModel.videoPlaybackViewModel( apiClient: self.sessionController.client, - dismissClosure: dismissClosure + dismissClosure: { + self.currentlyDisplayedVideoPlaybackViewModel = nil + } ) - } - - return AnyView(NavigationLink(destination: - VideoView(viewModelProvider: childVideoPlaybackViewModelProvider) - ) { - TextListItemView( - dynamicContentViewModel: childDynamicContentViewModel, - content: model - ) - .padding([.horizontal, .bottom], 20) - } - //HACK: to remove navigation chevrons - .padding(.trailing, -15.0) - ) + }) { + TextListItemView( + dynamicContentViewModel: childDynamicContentViewModel, + content: model + ) + .padding([.horizontal, .bottom], 20) + }) } } diff --git a/Emitron/Emitron/UI/Shared/Content Detail/ContentDetailView.swift b/Emitron/Emitron/UI/Shared/Content Detail/ContentDetailView.swift index 7884f853..9cc995f8 100644 --- a/Emitron/Emitron/UI/Shared/Content Detail/ContentDetailView.swift +++ b/Emitron/Emitron/UI/Shared/Content Detail/ContentDetailView.swift @@ -31,6 +31,8 @@ import KingfisherSwiftUI import UIKit struct ContentDetailView: View { + @State private var currentlyDisplayedVideoPlaybackViewModel: VideoPlaybackViewModel? + var content: ContentListDisplayable @ObservedObject var childContentsViewModel: ChildContentsViewModel @ObservedObject var dynamicContentViewModel: DynamicContentViewModel @@ -48,7 +50,13 @@ struct ContentDetailView: View { var maxImageHeight: CGFloat = 384 var body: some View { - contentView + ZStack { + contentView + + if currentlyDisplayedVideoPlaybackViewModel != nil { + FullScreenVideoPlayerRepresentable(viewModel: $currentlyDisplayedVideoPlaybackViewModel) + } + } } var contentView: some View { @@ -69,7 +77,10 @@ struct ContentDetailView: View { .listRowInsets(EdgeInsets()) .listRowBackground(Color.backgroundColor) - ChildContentListingView(childContentsViewModel: self.childContentsViewModel) + ChildContentListingView( + childContentsViewModel: self.childContentsViewModel, + currentlyDisplayedVideoPlaybackViewModel: self.$currentlyDisplayedVideoPlaybackViewModel + ) .background(Color.backgroundColor) } } @@ -86,14 +97,15 @@ struct ContentDetailView: View { } } - private var continueOrPlayButton: NavigationLink { - let viewModelProvider: VideoViewModelProvider = { dismissClosure in - self.dynamicContentViewModel.videoPlaybackViewModel( + private var continueOrPlayButton: Button { + Button(action: { + self.currentlyDisplayedVideoPlaybackViewModel = self.dynamicContentViewModel.videoPlaybackViewModel( apiClient: self.sessionController.client, - dismissClosure: dismissClosure + dismissClosure: { + self.currentlyDisplayedVideoPlaybackViewModel = nil + } ) - } - return NavigationLink(destination: VideoView(viewModelProvider: viewModelProvider)) { + }) { if case .hasData = childContentsViewModel.state { if case .inProgress = dynamicContentViewModel.viewProgress { return AnyView(ContinueButtonView()) @@ -123,7 +135,6 @@ struct ContentDetailView: View { ) continueOrPlayButton - .padding(.trailing, -32.0) // HACK: to remove navigation chevrons } progressBar diff --git a/Emitron/Emitron/UI/Video/FullScreenVideoPlayerRepresentable.swift b/Emitron/Emitron/UI/Video/FullScreenVideoPlayerRepresentable.swift new file mode 100644 index 00000000..ebc996b4 --- /dev/null +++ b/Emitron/Emitron/UI/Video/FullScreenVideoPlayerRepresentable.swift @@ -0,0 +1,41 @@ +// Copyright (c) 2020 Razeware LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, +// distribute, sublicense, create a derivative work, and/or sell copies of the +// Software in any work that is designed, intended, or marketed for pedagogical or +// instructional purposes related to programming, coding, application development, +// or information technology. Permission for such use, copying, modification, +// merger, publication, distribution, sublicensing, creation of derivative works, +// or sale is expressly withheld. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import SwiftUI + +struct FullScreenVideoPlayerRepresentable: UIViewControllerRepresentable { + @Binding var viewModel: VideoPlaybackViewModel? + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> FullScreenVideoPlayerViewController { + FullScreenVideoPlayerViewController(viewModel: $viewModel) + } + + func updateUIViewController(_ uiViewController: FullScreenVideoPlayerViewController, context: UIViewControllerRepresentableContext) { + // No-op + } +} diff --git a/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift b/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift new file mode 100644 index 00000000..6a9fdec8 --- /dev/null +++ b/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift @@ -0,0 +1,104 @@ +// Copyright (c) 2020 Razeware LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, +// distribute, sublicense, create a derivative work, and/or sell copies of the +// Software in any work that is designed, intended, or marketed for pedagogical or +// instructional purposes related to programming, coding, application development, +// or information technology. Permission for such use, copying, modification, +// merger, publication, distribution, sublicensing, creation of derivative works, +// or sale is expressly withheld. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import UIKit +import AVKit +import SwiftUI + +class FullScreenVideoPlayerViewController: UIViewController { + @Binding var viewModel: VideoPlaybackViewModel? + private var isFullscreen: Bool = false + + init(viewModel: Binding) { + self._viewModel = viewModel + super.init(nibName: nil, bundle: nil) + + self.viewModel?.reloadIfRequired() + self.verifyVideoPlaybackAllowed() + } + + required init?(coder: NSCoder) { + preconditionFailure("init(coder:) has not been implemented") + } +} + +extension FullScreenVideoPlayerViewController { + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if !isFullscreen { + let viewController = LandscapeAVPlayerViewController() + viewController.player = viewModel?.player + viewController.delegate = self + present(viewController, animated: true) + } + } +} + +extension FullScreenVideoPlayerViewController { + private func verifyVideoPlaybackAllowed() { + guard let viewModel = viewModel else { return } + do { + try _ = viewModel.canPlayOrDisplayError() + } catch { + if let viewModelError = error as? VideoPlaybackViewModelError { + MessageBus.current.post( + message: Message( + level: viewModelError.messageLevel, + message: viewModelError.localizedDescription, + autoDismiss: viewModelError.messageAutoDismiss + ) + ) + } + disappear() + } + } + + private func disappear() { + dismiss(animated: true) { + self.viewModel = nil + } + } +} + +extension FullScreenVideoPlayerViewController: AVPlayerViewControllerDelegate { + func playerViewController( + _ playerViewController: AVPlayerViewController, + willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + isFullscreen = true + } + + func playerViewController( + _ playerViewController: AVPlayerViewController, + willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + coordinator.animate(alongsideTransition: nil) { context in + guard !context.isCancelled else { return } + // Exited fullscreen, so let's disappear ourselves + self.disappear() + } + } +} diff --git a/Emitron/Emitron/UI/Video/LandscapeAVPlayerViewController.swift b/Emitron/Emitron/UI/Video/LandscapeAVPlayerViewController.swift new file mode 100644 index 00000000..9170e6fa --- /dev/null +++ b/Emitron/Emitron/UI/Video/LandscapeAVPlayerViewController.swift @@ -0,0 +1,35 @@ +// Copyright (c) 2020 Razeware LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, +// distribute, sublicense, create a derivative work, and/or sell copies of the +// Software in any work that is designed, intended, or marketed for pedagogical or +// instructional purposes related to programming, coding, application development, +// or information technology. Permission for such use, copying, modification, +// merger, publication, distribution, sublicensing, creation of derivative works, +// or sale is expressly withheld. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import AVKit + +class LandscapeAVPlayerViewController: AVPlayerViewController { + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + .landscape + } +} diff --git a/Emitron/Emitron/UI/Video/VideoView.swift b/Emitron/Emitron/UI/Video/VideoView.swift deleted file mode 100644 index 67d72ac0..00000000 --- a/Emitron/Emitron/UI/Video/VideoView.swift +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) 2019 Razeware LLC -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, -// distribute, sublicense, create a derivative work, and/or sell copies of the -// Software in any work that is designed, intended, or marketed for pedagogical or -// instructional purposes related to programming, coding, application development, -// or information technology. Permission for such use, copying, modification, -// merger, publication, distribution, sublicensing, creation of derivative works, -// or sale is expressly withheld. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import SwiftUI -import AVKit - -struct VideoPlayerControllerRepresentable: UIViewControllerRepresentable { - private let viewModel: VideoPlaybackViewModel - - init(with viewModel: VideoPlaybackViewModel) { - self.viewModel = viewModel - } - - func makeUIViewController(context: UIViewControllerRepresentableContext) -> AVPlayerViewController { - let viewController = AVPlayerViewController() - viewController.player = viewModel.player - return viewController - } - - func updateUIViewController(_ uiViewController: VideoPlayerControllerRepresentable.UIViewControllerType, context: UIViewControllerRepresentableContext) { - // No-op - } -} - -typealias VideoViewModelProvider = (_ dismiss: @escaping () -> Void) -> VideoPlaybackViewModel - -struct VideoView: View { - let viewModelProvider: VideoViewModelProvider - @State private var viewModel: VideoPlaybackViewModel? - - @Environment(\.presentationMode) var presentationMode - @EnvironmentObject var tabViewModel: TabViewModel - - // This is a bit hacky, but I reckon it might work - @State private var owningTab: MainTab? - - @State private var settingsPresented: Bool = false - @State private var playbackVerified: Bool = false - - var body: some View { - videoView - .navigationBarItems(trailing: - SwiftUI.Group { - Button(action: { - self.settingsPresented = true - }) { - Image("settings") - .foregroundColor(.iconButton) - } - }) - .sheet(isPresented: self.$settingsPresented) { - SettingsView(showLogoutButton: false) - } - .onDisappear { - // Only pause the video if we've dismissed the video. - // Otherwise, we pause it when we switch to full screen. - if !self.presentationMode.wrappedValue.isPresented { - self.viewModel?.pause() - } - // Also want to pause if we've switched to a different tab - if self.owningTab != self.tabViewModel.selectedTab { - self.viewModel?.pause() - } - // No-op for other cases (including entering fullscreen video playback) - } - .onAppear { - self.checkViewModelLoaded() - self.storeOwningTab() - self.viewModel?.reloadIfRequired() - self.verifyVideoPlaybackAllowed() - self.viewModel?.play() - } - } - - private var videoView: AnyView? { - if let viewModel = viewModel { - return AnyView(VideoPlayerControllerRepresentable(with: viewModel)) - } - return nil - } - - private func checkViewModelLoaded() { - guard self.viewModel == nil else { return } - - self.viewModel = self.viewModelProvider { - self.presentationMode.wrappedValue.dismiss() - } - } - - private func storeOwningTab() { - guard self.owningTab == nil else { return } - - self.owningTab = self.tabViewModel.selectedTab - } - - private func verifyVideoPlaybackAllowed() { - guard !playbackVerified, let viewModel = viewModel else { return } - do { - if try viewModel.canPlayOrDisplayError() { - playbackVerified = true - } - } catch { - self.presentationMode.wrappedValue.dismiss() - if let viewModelError = error as? VideoPlaybackViewModelError { - MessageBus.current.post( - message: Message( - level: viewModelError.messageLevel, - message: viewModelError.localizedDescription, - autoDismiss: viewModelError.messageAutoDismiss - ) - ) - } - } - } -} From bbafd0d92610b7ddc15336ac78ea8154c3aebe63 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Mon, 23 Mar 2020 13:54:11 +0000 Subject: [PATCH 02/18] #439: Allow the fullscreen video to be in any orientation This will prevent the weird rotation artefact too --- Emitron/Emitron.xcodeproj/project.pbxproj | 4 --- .../FullScreenVideoPlayerViewController.swift | 2 +- .../LandscapeAVPlayerViewController.swift | 35 ------------------- 3 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 Emitron/Emitron/UI/Video/LandscapeAVPlayerViewController.swift diff --git a/Emitron/Emitron.xcodeproj/project.pbxproj b/Emitron/Emitron.xcodeproj/project.pbxproj index 3dad6877..d6e47025 100644 --- a/Emitron/Emitron.xcodeproj/project.pbxproj +++ b/Emitron/Emitron.xcodeproj/project.pbxproj @@ -196,7 +196,6 @@ 22C3F125241A2655002812CB /* app-icon--default.dev-ipadpro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 22C3F11F241A2654002812CB /* app-icon--default.dev-ipadpro@2x.png */; }; 22C3F126241A2655002812CB /* app-icon--default.beta-ipadpro@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 22C3F120241A2654002812CB /* app-icon--default.beta-ipadpro@2x.png */; }; 22C3F127241A2655002812CB /* app-icon--default.dev-ipad@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 22C3F121241A2655002812CB /* app-icon--default.dev-ipad@2x.png */; }; - 22C3F1532427954A002812CB /* LandscapeAVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C3F1522427954A002812CB /* LandscapeAVPlayerViewController.swift */; }; 22C3F155242795A1002812CB /* PortraitHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C3F154242795A1002812CB /* PortraitHostingController.swift */; }; 22C3F15724279692002812CB /* FullScreenVideoPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C3F15624279692002812CB /* FullScreenVideoPlayerViewController.swift */; }; 22C3F159242796EC002812CB /* FullScreenVideoPlayerRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22C3F158242796EC002812CB /* FullScreenVideoPlayerRepresentable.swift */; }; @@ -534,7 +533,6 @@ 22C3F11F241A2654002812CB /* app-icon--default.dev-ipadpro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "app-icon--default.dev-ipadpro@2x.png"; sourceTree = ""; }; 22C3F120241A2654002812CB /* app-icon--default.beta-ipadpro@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "app-icon--default.beta-ipadpro@2x.png"; sourceTree = ""; }; 22C3F121241A2655002812CB /* app-icon--default.dev-ipad@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "app-icon--default.dev-ipad@2x.png"; sourceTree = ""; }; - 22C3F1522427954A002812CB /* LandscapeAVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapeAVPlayerViewController.swift; sourceTree = ""; }; 22C3F154242795A1002812CB /* PortraitHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHostingController.swift; sourceTree = ""; }; 22C3F15624279692002812CB /* FullScreenVideoPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenVideoPlayerViewController.swift; sourceTree = ""; }; 22C3F158242796EC002812CB /* FullScreenVideoPlayerRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenVideoPlayerRepresentable.swift; sourceTree = ""; }; @@ -1042,7 +1040,6 @@ 229F0AC323BB29230004DD4F /* Video */ = { isa = PBXGroup; children = ( - 22C3F1522427954A002812CB /* LandscapeAVPlayerViewController.swift */, 22C3F15624279692002812CB /* FullScreenVideoPlayerViewController.swift */, 22C3F158242796EC002812CB /* FullScreenVideoPlayerRepresentable.swift */, ); @@ -2084,7 +2081,6 @@ 22C640FA23F609B600CBFDE5 /* SearchFieldView.swift in Sources */, 22BE654F23F1F66E00717369 /* Comparable+Clamped.swift in Sources */, B6C4F3D222E6ECA50087ED10 /* CheckmarkView.swift in Sources */, - 22C3F1532427954A002812CB /* LandscapeAVPlayerViewController.swift in Sources */, 22B8265D23AF37FE00D4BA23 /* ProgressionAdapter.swift in Sources */, 222C0AB023DF554E00D65EBD /* SettingsOption.swift in Sources */, 2278AE50240A74C400855221 /* UIApplication+DismissKeyboard.swift in Sources */, diff --git a/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift b/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift index 6a9fdec8..026c9350 100644 --- a/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift +++ b/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift @@ -51,7 +51,7 @@ extension FullScreenVideoPlayerViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if !isFullscreen { - let viewController = LandscapeAVPlayerViewController() + let viewController = AVPlayerViewController() viewController.player = viewModel?.player viewController.delegate = self present(viewController, animated: true) diff --git a/Emitron/Emitron/UI/Video/LandscapeAVPlayerViewController.swift b/Emitron/Emitron/UI/Video/LandscapeAVPlayerViewController.swift deleted file mode 100644 index 9170e6fa..00000000 --- a/Emitron/Emitron/UI/Video/LandscapeAVPlayerViewController.swift +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2020 Razeware LLC -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// Notwithstanding the foregoing, you may not use, copy, modify, merge, publish, -// distribute, sublicense, create a derivative work, and/or sell copies of the -// Software in any work that is designed, intended, or marketed for pedagogical or -// instructional purposes related to programming, coding, application development, -// or information technology. Permission for such use, copying, modification, -// merger, publication, distribution, sublicensing, creation of derivative works, -// or sale is expressly withheld. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import AVKit - -class LandscapeAVPlayerViewController: AVPlayerViewController { - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - .landscape - } -} From f46a4711d762acba069f06e445c40cab88895f69 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Mon, 23 Mar 2020 17:01:25 +0000 Subject: [PATCH 03/18] #437: Video now autoplays --- .../Emitron/UI/Video/FullScreenVideoPlayerViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift b/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift index 026c9350..b4c43ecc 100644 --- a/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift +++ b/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift @@ -55,6 +55,7 @@ extension FullScreenVideoPlayerViewController { viewController.player = viewModel?.player viewController.delegate = self present(viewController, animated: true) + viewModel?.play() } } } From baa67e06e4df1f0e1733711a9c5bcf70cc0c961f Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Mon, 23 Mar 2020 17:51:21 +0000 Subject: [PATCH 04/18] Respecting the playback speed setting This should also address #364 --- .../Data/ViewModels/VideoPlaybackViewModel.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift index 55401d59..ebdb6181 100644 --- a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift @@ -209,6 +209,17 @@ final class VideoPlaybackViewModel { self.handleTimeUpdate(time: time) } + player.publisher(for: \.rate) + .removeDuplicates() + .sink { [weak self] rate in + guard let self = self, + rate != 0, + rate != SettingsManager.current.playbackSpeed.rate else { return } + + self.player.rate = SettingsManager.current.playbackSpeed.rate + } + .store(in: &subscriptions) + SettingsManager.current .playbackSpeedPublisher .removeDuplicates() From 10efda8c49bbef087b5c514bf97c8d0ec3c74cdc Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 24 Mar 2020 08:17:40 +0000 Subject: [PATCH 05/18] Adding metadata to the player item. This gives a better experience for AirPlay. --- .../ViewModels/VideoPlaybackViewModel.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift index ebdb6181..60479d41 100644 --- a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift @@ -35,6 +35,7 @@ enum VideoPlaybackViewModelError: Error { case cannotStreamWhenOffline case invalidPermissions case expiredPermissions + case unableToLoadArtwork var localizedDescription: String { switch self { @@ -46,6 +47,8 @@ enum VideoPlaybackViewModelError: Error { return Constants.videoPlaybackInvalidPermissions case .expiredPermissions: return Constants.videoPlaybackExpiredPermissions + case .unableToLoadArtwork: + return "VideoPlaybackViewModelError::unableToLoadArtwork" } } @@ -348,6 +351,7 @@ final class VideoPlaybackViewModel { download.state == .complete, let localUrl = download.localUrl { let item = AVPlayerItem(url: localUrl) + self.addMetadata(from: state, to: item) self.addClosedCaptions(for: item) // Add it to the cache self.playerItems[state.content.id] = item @@ -366,6 +370,7 @@ final class VideoPlaybackViewModel { case .success(let response): guard response.kind == .stream else { return promise(.failure(VideoPlaybackViewModelError.invalidOrMissingAttribute("Not A Stream"))) } let item = AVPlayerItem(url: response.url) + self.addMetadata(from: state, to: item) self.addClosedCaptions(for: item) // Add it to the cache self.playerItems[state.content.id] = item @@ -387,6 +392,37 @@ final class VideoPlaybackViewModel { } } } + + private func addMetadata(from state: VideoPlaybackState, to playerItem: AVPlayerItem) { + let title = AVMutableMetadataItem() + title.identifier = .commonIdentifierTitle + title.value = state.content.name as NSString + + let description = AVMutableMetadataItem() + description.identifier = .commonIdentifierDescription + description.value = state.content.descriptionPlainText as NSString + + let artwork = AVMutableMetadataItem() + artwork.identifier = .commonIdentifierArtwork + + let deferredArtwork = AVMetadataItem(propertiesOf: artwork) { request in + guard let url = state.content.cardArtworkUrl else { + request.respond(error: VideoPlaybackViewModelError.unableToLoadArtwork) + return + } + + let task = URLSession.shared.dataTask(with: url) { data, _, _ in + guard let data = data else { + request.respond(error: VideoPlaybackViewModelError.unableToLoadArtwork) + return + } + request.respond(value: data as NSData) + } + task.resume() + } + + playerItem.externalMetadata = [title, description, deferredArtwork] + } private func update(progression: Progression) { // Find appropriate playback state From 33ecb4fdde6c189f90c47bba9b04d97222f243b0 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Wed, 25 Mar 2020 17:01:50 +0000 Subject: [PATCH 06/18] Fixes iOS 13.4, by removing a load of hacks no longer required This also limits the iOS release version. --- Emitron/Emitron.xcodeproj/project.pbxproj | 6 +++--- Emitron/Emitron/UI/App Root/TabNavView.swift | 1 - Emitron/Emitron/UI/SceneDelegate.swift | 17 ----------------- 3 files changed, 3 insertions(+), 21 deletions(-) diff --git a/Emitron/Emitron.xcodeproj/project.pbxproj b/Emitron/Emitron.xcodeproj/project.pbxproj index d6e47025..b3bf4424 100644 --- a/Emitron/Emitron.xcodeproj/project.pbxproj +++ b/Emitron/Emitron.xcodeproj/project.pbxproj @@ -2384,7 +2384,7 @@ DEVELOPMENT_TEAM = KFCNEC27GU; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2588,7 +2588,7 @@ DEVELOPMENT_TEAM = KFCNEC27GU; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2615,7 +2615,7 @@ DEVELOPMENT_TEAM = KFCNEC27GU; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Emitron/Emitron/UI/App Root/TabNavView.swift b/Emitron/Emitron/UI/App Root/TabNavView.swift index 943805f1..aa2d5831 100644 --- a/Emitron/Emitron/UI/App Root/TabNavView.swift +++ b/Emitron/Emitron/UI/App Root/TabNavView.swift @@ -70,7 +70,6 @@ struct TabNavView: View { .accessibility(label: Text(Constants.myTutorials)) } .accentColor(Color.accent) - .edgesIgnoringSafeArea([.top]) } } diff --git a/Emitron/Emitron/UI/SceneDelegate.swift b/Emitron/Emitron/UI/SceneDelegate.swift index 6e40e952..6cee3cfc 100644 --- a/Emitron/Emitron/UI/SceneDelegate.swift +++ b/Emitron/Emitron/UI/SceneDelegate.swift @@ -77,23 +77,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // TODO: When a modifier is available this should be refactored window.tintColor = UIColor.accent - let statusBarHeight: CGFloat = windowScene.statusBarManager?.statusBarFrame.height ?? 0 - - let statusbarView = UIView() - statusbarView.backgroundColor = UIColor.backgroundColor - let view = window.rootViewController!.view! - view.addSubview(statusbarView) - - statusbarView.translatesAutoresizingMaskIntoConstraints = false - statusbarView.heightAnchor - .constraint(equalToConstant: statusBarHeight).isActive = true - statusbarView.widthAnchor - .constraint(equalTo: view.widthAnchor, multiplier: 1.0).isActive = true - statusbarView.topAnchor - .constraint(equalTo: view.topAnchor).isActive = true - statusbarView.centerXAnchor - .constraint(equalTo: view.centerXAnchor).isActive = true - window.makeKeyAndVisible() } } From f328f25b0ed9720afeb5afb96a2cf7419f022def Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Wed, 25 Mar 2020 17:12:37 +0000 Subject: [PATCH 07/18] Updating CI to Xcode 11.4 --- .github/workflows/appstore-upload.yml | 4 ++-- .github/workflows/run_tests.yml | 4 ++-- .github/workflows/testflight-beta.yml | 4 ++-- .github/workflows/testflight-release.yml | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/appstore-upload.yml b/.github/workflows/appstore-upload.yml index 56e865bd..54fe3b59 100644 --- a/.github/workflows/appstore-upload.yml +++ b/.github/workflows/appstore-upload.yml @@ -12,8 +12,8 @@ jobs: steps: - uses: actions/checkout@v1 - - name: Switch to Xcode 11.3 - run: sudo xcode-select --switch /Applications/Xcode_11.3.app + - name: Switch to Xcode 11.4 + run: sudo xcode-select --switch /Applications/Xcode_11.4.app - name: Update fastlane run: | cd Emitron diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 6958ce70..75eb5f7d 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -12,8 +12,8 @@ jobs: steps: - uses: actions/checkout@v1 - - name: Switch to Xcode 11.3 - run: sudo xcode-select --switch /Applications/Xcode_11.3.app + - name: Switch to Xcode 11.4 + run: sudo xcode-select --switch /Applications/Xcode_11.4.app - name: Update fastlane run: | cd Emitron diff --git a/.github/workflows/testflight-beta.yml b/.github/workflows/testflight-beta.yml index ed0f6514..aaeefd54 100644 --- a/.github/workflows/testflight-beta.yml +++ b/.github/workflows/testflight-beta.yml @@ -12,8 +12,8 @@ jobs: steps: - uses: actions/checkout@v1 - - name: Switch to Xcode 11.3 - run: sudo xcode-select --switch /Applications/Xcode_11.3.app + - name: Switch to Xcode 11.4 + run: sudo xcode-select --switch /Applications/Xcode_11.4.app - name: Update fastlane run: | cd Emitron diff --git a/.github/workflows/testflight-release.yml b/.github/workflows/testflight-release.yml index 216e534a..4202e2a8 100644 --- a/.github/workflows/testflight-release.yml +++ b/.github/workflows/testflight-release.yml @@ -12,8 +12,8 @@ jobs: steps: - uses: actions/checkout@v1 - - name: Switch to Xcode 11.3 - run: sudo xcode-select --switch /Applications/Xcode_11.3.app + - name: Switch to Xcode 11.4 + run: sudo xcode-select --switch /Applications/Xcode_11.4.app - name: Update fastlane run: | cd Emitron From ed22a8a865e5a8a85e198c846f134ff194eb17fa Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Wed, 25 Mar 2020 17:32:00 +0000 Subject: [PATCH 08/18] Moving test target to 13.4 too --- Emitron/Emitron.xcodeproj/project.pbxproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Emitron/Emitron.xcodeproj/project.pbxproj b/Emitron/Emitron.xcodeproj/project.pbxproj index b3bf4424..e66549d6 100644 --- a/Emitron/Emitron.xcodeproj/project.pbxproj +++ b/Emitron/Emitron.xcodeproj/project.pbxproj @@ -2406,6 +2406,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = KFCNEC27GU; INFOPLIST_FILE = emitronTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2426,6 +2427,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = KFCNEC27GU; INFOPLIST_FILE = emitronTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2446,6 +2448,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = KFCNEC27GU; INFOPLIST_FILE = emitronTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", From a3b2a2a6871093d82f529f3bb1cb0462b068e106 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 7 Apr 2020 10:54:32 +0100 Subject: [PATCH 09/18] #445: Logging out dismisses the sheet again This broke in 13.4, and so this adds a bit of a hack --- Emitron/Emitron/UI/Settings/SettingsView.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Emitron/Emitron/UI/Settings/SettingsView.swift b/Emitron/Emitron/UI/Settings/SettingsView.swift index e7dd8ab3..8aed55a6 100644 --- a/Emitron/Emitron/UI/Settings/SettingsView.swift +++ b/Emitron/Emitron/UI/Settings/SettingsView.swift @@ -85,9 +85,12 @@ struct SettingsView: View { .foregroundColor(.contentText) } MainButtonView(title: "Sign Out", type: .destructive(withArrow: true)) { - self.sessionController.logout() self.presentationMode.wrappedValue.dismiss() - self.tabViewModel.selectedTab = .library + // This is hacky. But without it, the sheet doesn't actually dismiss. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.sessionController.logout() + self.tabViewModel.selectedTab = .library + } } } .padding([.bottom, .horizontal], 18) From ec956ae2575e905c316cc7e07d2c4d03d435bb7e Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 7 Apr 2020 11:52:16 +0100 Subject: [PATCH 10/18] #448: Swipe to delete for downloads works again It was fighting with keyboard dismissal --- .../Shared/Content List/ContentListView.swift | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift b/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift index 9f68d131..fc7636d0 100644 --- a/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift +++ b/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift @@ -68,7 +68,7 @@ struct ContentListView: View { } } - private func cardTableNavView(withDelete: Bool = false) -> some View { + private var cardsView: some View { ForEach(contentRepository.contents, id: \.id) { partialContent in ZStack { CardViewContainer( @@ -82,7 +82,7 @@ struct ContentListView: View { .padding(.trailing, -2 * .sidePadding) } } - .if(withDelete) { $0.onDelete(perform: self.delete) } + .if(allowDelete) { $0.onDelete(perform: self.delete) } .listRowInsets(EdgeInsets()) .padding([.horizontal, .top], .sidePadding) .background(Color.backgroundColor) @@ -99,35 +99,36 @@ struct ContentListView: View { } } - private var appropriateCardsView: some View { + private var allowDelete: Bool { if case .downloads = contentScreen { - return cardTableNavView(withDelete: true) - } else { - return cardTableNavView(withDelete: false) + return true } + return false } private var listView: some View { List { if self.headerView != nil { Section(header: self.headerView) { - self.appropriateCardsView + self.cardsView self.loadMoreView // Hack to make sure there's some spacing at the bottom of the list Color.clear.frame(height: 0) }.listRowInsets(EdgeInsets()) } else { - self.appropriateCardsView + self.cardsView self.loadMoreView // Hack to make sure there's some spacing at the bottom of the list Color.clear.frame(height: 0) } } - .gesture( - DragGesture().onChanged { _ in - UIApplication.dismissKeyboard() - } - ) + .if(!allowDelete) { + $0.gesture( + DragGesture().onChanged { _ in + UIApplication.dismissKeyboard() + } + ) + } .accessibility(identifier: "contentListView") } From 6283ccddbe85ebbc4a262a0d30fae3bac2261b9d Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 7 Apr 2020 12:38:10 +0100 Subject: [PATCH 11/18] #444: Wait until item is ready before beginning playback This should ensure that sound is available when playing back at increased playback rates. --- .../ViewModels/VideoPlaybackViewModel.swift | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift index 60479d41..9455a5af 100644 --- a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift @@ -99,10 +99,12 @@ final class VideoPlaybackViewModel { contentList[nextContentToEnqueueIndex] } private var subscriptions = Set() + private var currentItemStateSubscription: AnyCancellable? let player = AVQueuePlayer() private var playerTimeObserverToken: Any? var state: DataState = .initial + private var shouldBePlaying = false init(contentId: Int, repository: Repository, @@ -192,10 +194,11 @@ final class VideoPlaybackViewModel { func play() { self.progressEngine.playbackStarted() - self.player.play() + self.shouldBePlaying = true } func pause() { + self.shouldBePlaying = false self.player.pause() } @@ -215,6 +218,8 @@ final class VideoPlaybackViewModel { player.publisher(for: \.rate) .removeDuplicates() .sink { [weak self] rate in + self?.shouldBePlaying = rate == 0 + guard let self = self, rate != 0, rate != SettingsManager.current.playbackSpeed.rate else { return } @@ -223,6 +228,25 @@ final class VideoPlaybackViewModel { } .store(in: &subscriptions) + player.publisher(for: \.currentItem) + .removeDuplicates() + .sink { [weak self] item in + guard let self = self, + let item = item else { return } + + self.currentItemStateSubscription = item.publisher(for: \.status) + .removeDuplicates() + .sink { [weak self] status in + guard let self = self, + status == .readyToPlay, + self.shouldBePlaying, + self.player.rate == 0 else { return } + + self.player.play() + } + } + .store(in: &subscriptions) + SettingsManager.current .playbackSpeedPublisher .removeDuplicates() From 21f3e98379f6bb9875e082ff79c099d0c656523d Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 7 Apr 2020 13:05:35 +0100 Subject: [PATCH 12/18] #447: Ensure that logging out / in will reset the cache --- Emitron/Emitron/Sessions/SessionController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emitron/Emitron/Sessions/SessionController.swift b/Emitron/Emitron/Sessions/SessionController.swift index 8f7920be..4c44841d 100644 --- a/Emitron/Emitron/Sessions/SessionController.swift +++ b/Emitron/Emitron/Sessions/SessionController.swift @@ -51,7 +51,7 @@ class SessionController: NSObject, UserModelController, ObservablePrePostFactoOb // Managing the state of the current session private(set) var sessionState: SessionState = .unknown - private(set) var userState: UserState = .notLoggedIn + @Published private(set) var userState: UserState = .notLoggedIn @Published private(set) var permissionState: PermissionState = .notLoaded @PublishedPrePostFacto var user: User? { From 12fd5f7bbd8473fcd84d60937c6d8e7c1b309dfc Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 7 Apr 2020 18:48:24 +0100 Subject: [PATCH 13/18] #438: Changing the "play next" logic It'll now continue from the first incomplete video --- Emitron/Emitron/Data/DataCache.swift | 29 +++++++++++----------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/Emitron/Emitron/Data/DataCache.swift b/Emitron/Emitron/Data/DataCache.swift index 9f47e6ec..82eac1a3 100644 --- a/Emitron/Emitron/Data/DataCache.swift +++ b/Emitron/Emitron/Data/DataCache.swift @@ -321,26 +321,19 @@ extension DataCache { guard !contentList.isEmpty else { throw DataCacheError.cacheMiss } // We'll assume that the contents is already ordered. It is if it comes from child/sibling contents - let orderedProgressions = contentList.compactMap { progressions[$0.id] } + let orderedProgressions = contentList.map { progressions[$0.id] } - // No child progressions—let's start with the first item of content - if orderedProgressions.isEmpty { - return contentList.first! + // Find the first index where there's a missing or incomplete progression + guard let incompleteOrNotStartedIndex = orderedProgressions.firstIndex(where: { progression in + guard let progression = progression else { return true } + + return !progression.finished + }) else { + // If we didn't find one, start at the beginning + return contentList[0] } - // The last progression is the furthest through the content - guard let lastProgression = orderedProgressions.last, - let lastContentIndex = contentList.firstIndex(where: { $0.id == lastProgression.contentId }) - else { return contentList[0] } - // If it's not finished, then we're part way through it—return this item of content - if !lastProgression.finished { - return contentList[lastContentIndex] - } - // Need the next one—does it exist? - if lastContentIndex + 1 < contentList.endIndex { - return contentList[lastContentIndex + 1] - } - // Must have completed the final episode. Let's start at the beginning again. - return contentList[0] + // Otherwise, we've found the one we need + return contentList[incompleteOrNotStartedIndex] } } From 1de4f7e5bfcdeea8cf29258d854ba17aa132e664 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 7 Apr 2020 18:52:04 +0100 Subject: [PATCH 14/18] Increasing test timing --- .../Persistence/PersistenceStore+DownloadsTest.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift b/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift index 3942a7c4..66f5162a 100644 --- a/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift +++ b/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift @@ -187,7 +187,7 @@ class PersistenceStore_DownloadsTest: XCTestCase { collectionExpectation.fulfill() } - wait(for: [collectionExpectation], timeout: 1) + wait(for: [collectionExpectation], timeout: 2) } func testTransitionNonFinalEpisodeToDownloadedUpdatesCollection() throws { From 16dbe244ab99fdb10de7cccd996b82970322cca7 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 7 Apr 2020 18:56:32 +0100 Subject: [PATCH 15/18] #434: Beginners can now bookmark pro content --- .../Content Detail/ContentSummaryView.swift | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/Emitron/Emitron/UI/Shared/Content Detail/ContentSummaryView.swift b/Emitron/Emitron/UI/Shared/Content Detail/ContentSummaryView.swift index 92b5dca8..840e8cc5 100644 --- a/Emitron/Emitron/UI/Shared/Content Detail/ContentSummaryView.swift +++ b/Emitron/Emitron/UI/Shared/Content Detail/ContentSummaryView.swift @@ -79,23 +79,21 @@ struct ContentSummaryView: View { .lineSpacing(3) .padding([.top], 10) - if !courseLocked { - HStack(spacing: 30, content: { - if canDownload { - DownloadIcon(downloadProgress: dynamicContentViewModel.downloadProgress) - .onTapGesture { - self.download() - } - .alert(item: $deletionConfirmation) { $0.alert } - .accessibility(label: Text("\(dynamicContentViewModel.downloadProgress.accessibilityDescription) course")) - } - - bookmarkButton - - completedTag - }) - .padding([.top], 15) - } + HStack(spacing: 30, content: { + if canDownload { + DownloadIcon(downloadProgress: dynamicContentViewModel.downloadProgress) + .onTapGesture { + self.download() + } + .alert(item: $deletionConfirmation) { $0.alert } + .accessibility(label: Text("\(dynamicContentViewModel.downloadProgress.accessibilityDescription) course")) + } + + bookmarkButton + + completedTag + }) + .padding([.top], 15) Text(content.descriptionPlainText) .font(.uiCaption) From db5063abde3e2f771eb5b1e3b6f8d418f8bb8288 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 7 Apr 2020 19:05:08 +0100 Subject: [PATCH 16/18] #450: Subscribers shouldn't be capitalised --- Emitron/Emitron/UI/App Root/LoginView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Emitron/Emitron/UI/App Root/LoginView.swift b/Emitron/Emitron/UI/App Root/LoginView.swift index 7d788920..952e0727 100644 --- a/Emitron/Emitron/UI/App Root/LoginView.swift +++ b/Emitron/Emitron/UI/App Root/LoginView.swift @@ -53,7 +53,7 @@ struct LoginView: View { .multilineTextAlignment(.center) .padding([.bottom], 15) - Text("raywenderlich Subscribers can watch over\n2,000+ video tutorials on iPhone and iPad.") + Text("raywenderlich subscribers can watch over\n2,000+ video tutorials on iPhone and iPad.") .font(.uiLabel) .foregroundColor(.contentText) .multilineTextAlignment(.center) @@ -73,7 +73,7 @@ struct LoginView: View { .multilineTextAlignment(.center) .padding([.bottom], 15) - Text("Professional Subscribers can download and\nwatch videos even when they're offline.") + Text("Professional subscribers can download and\nwatch videos even when they're offline.") .font(.uiLabel) .foregroundColor(.contentText) .multilineTextAlignment(.center) From 82dcc9125933b722f01c3aa28ab39e75527dd2c8 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 7 Apr 2020 21:21:54 +0100 Subject: [PATCH 17/18] #447: Make references to SyncAction weak, to prevent memory leak For some reason, on log out, we're capturing all kinds of guff in some closures. I don't understand why. However, as a result of that, we ended up with two SyncEngine objects. By making references to SyncAction weak, we no longer end up with multiple syncengines. It doesn't solve the underlying problem, but it prevents updating progress on the logged out user... --- .../Data Synchronisation/ProgressEngine.swift | 8 ++++---- .../Data Synchronisation/SyncAction.swift | 2 +- .../ContentRepository.swift | 2 +- .../ViewModels/ChildContentsViewModel.swift | 4 ++-- .../DataCacheChildContentsViewModel.swift | 2 +- .../ViewModels/DynamicContentViewModel.swift | 18 +++++++++++------- .../ViewModels/VideoPlaybackViewModel.swift | 2 +- 7 files changed, 21 insertions(+), 17 deletions(-) diff --git a/Emitron/Emitron/Data Synchronisation/ProgressEngine.swift b/Emitron/Emitron/Data Synchronisation/ProgressEngine.swift index 6a0fb865..2bb41a4a 100644 --- a/Emitron/Emitron/Data Synchronisation/ProgressEngine.swift +++ b/Emitron/Emitron/Data Synchronisation/ProgressEngine.swift @@ -55,13 +55,13 @@ final class ProgressEngine { private let contentsService: ContentsService private let repository: Repository - private let syncAction: SyncAction + private weak var syncAction: SyncAction? private var mode: Mode = .offline private let networkMonitor = NWPathMonitor() private var playbackToken: String? - init(contentsService: ContentsService, repository: Repository, syncAction: SyncAction) { + init(contentsService: ContentsService, repository: Repository, syncAction: SyncAction?) { self.contentsService = contentsService self.repository = repository self.syncAction = syncAction @@ -101,8 +101,8 @@ final class ProgressEngine { switch mode { case .offline: do { - try syncAction.updateProgress(for: contentId, progress: progress) - try syncAction.recordWatchStats(for: contentId, secondsWatched: Constants.videoPlaybackProgressTrackingInterval) + try syncAction?.updateProgress(for: contentId, progress: progress) + try syncAction?.recordWatchStats(for: contentId, secondsWatched: Constants.videoPlaybackProgressTrackingInterval) return Future { promise in promise(.success(progression)) diff --git a/Emitron/Emitron/Data Synchronisation/SyncAction.swift b/Emitron/Emitron/Data Synchronisation/SyncAction.swift index 4ebea0f0..020156fd 100644 --- a/Emitron/Emitron/Data Synchronisation/SyncAction.swift +++ b/Emitron/Emitron/Data Synchronisation/SyncAction.swift @@ -28,7 +28,7 @@ import Foundation -protocol SyncAction { +protocol SyncAction: AnyObject { func createBookmark(for contentId: Int) throws func deleteBookmark(for contentId: Int) throws diff --git a/Emitron/Emitron/Data/ContentRepositories/ContentRepository.swift b/Emitron/Emitron/Data/ContentRepositories/ContentRepository.swift index 132b5b3d..676d7719 100644 --- a/Emitron/Emitron/Data/ContentRepositories/ContentRepository.swift +++ b/Emitron/Emitron/Data/ContentRepositories/ContentRepository.swift @@ -33,7 +33,7 @@ class ContentRepository: ObservableObject, ContentPaginatable { let repository: Repository let contentsService: ContentsService let downloadAction: DownloadAction - let syncAction: SyncAction + weak var syncAction: SyncAction? let serviceAdapter: ContentServiceAdapter! private (set) var currentPage: Int = 1 diff --git a/Emitron/Emitron/Data/ViewModels/ChildContentsViewModel.swift b/Emitron/Emitron/Data/ViewModels/ChildContentsViewModel.swift index d079cf52..18d3d6d3 100644 --- a/Emitron/Emitron/Data/ViewModels/ChildContentsViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/ChildContentsViewModel.swift @@ -32,7 +32,7 @@ import Combine class ChildContentsViewModel: ObservableObject { let parentContentId: Int let downloadAction: DownloadAction - let syncAction: SyncAction + weak var syncAction: SyncAction? let repository: Repository var state: DataState = .initial @@ -43,7 +43,7 @@ class ChildContentsViewModel: ObservableObject { init(parentContentId: Int, downloadAction: DownloadAction, - syncAction: SyncAction, + syncAction: SyncAction?, repository: Repository) { self.parentContentId = parentContentId self.downloadAction = downloadAction diff --git a/Emitron/Emitron/Data/ViewModels/DataCacheChildContentsViewModel.swift b/Emitron/Emitron/Data/ViewModels/DataCacheChildContentsViewModel.swift index af7c9e7e..2c5796b1 100644 --- a/Emitron/Emitron/Data/ViewModels/DataCacheChildContentsViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/DataCacheChildContentsViewModel.swift @@ -33,7 +33,7 @@ final class DataCacheChildContentsViewModel: ChildContentsViewModel { init(parentContentId: Int, downloadAction: DownloadAction, - syncAction: SyncAction, + syncAction: SyncAction?, repository: Repository, service: ContentsService) { self.service = service diff --git a/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift b/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift index fe354318..cf0c4e9e 100644 --- a/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift @@ -33,7 +33,7 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable private let contentId: Int private let repository: Repository private let downloadAction: DownloadAction - private let syncAction: SyncAction + private weak var syncAction: SyncAction? private var dynamicContentState: DynamicContentState? @@ -45,7 +45,7 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable private var subscriptions = Set() private var downloadActionSubscriptions = Set() - init(contentId: Int, repository: Repository, downloadAction: DownloadAction, syncAction: SyncAction) { + init(contentId: Int, repository: Repository, downloadAction: DownloadAction, syncAction: SyncAction?) { self.contentId = contentId self.repository = repository self.downloadAction = downloadAction @@ -69,12 +69,14 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable repository .contentDynamicState(for: contentId) .removeDuplicates() - .sink(receiveCompletion: { completion in - self.state = .failed + .sink(receiveCompletion: { [weak self] completion in + self?.state = .failed Failure .repositoryLoad(from: "DynamicContentViewModel", reason: "Unable to retrieve dynamic download content: \(completion)") .log() - }) { contentState in + }) { [weak self] contentState in + guard let self = self else { return } + self.viewProgress = ContentViewProgressDisplayable(progression: contentState.progression) self.downloadProgress = DownloadProgressDisplayable(download: contentState.download) self.bookmarked = contentState.bookmark != nil @@ -162,7 +164,8 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable } func bookmarkTapped() { - guard state == .hasData else { return } + guard state == .hasData, + let syncAction = syncAction else { return } if bookmarked { do { @@ -188,7 +191,8 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable } func completedTapped() { - guard state == .hasData else { return } + guard state == .hasData, + let syncAction = syncAction else { return } if case .completed = viewProgress { do { diff --git a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift index 9455a5af..7e609fcd 100644 --- a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift @@ -110,7 +110,7 @@ final class VideoPlaybackViewModel { repository: Repository, videosService: VideosService, contentsService: ContentsService, - syncAction: SyncAction, + syncAction: SyncAction?, sessionController: SessionController = .current, dismissClosure: @escaping () -> Void = { }) { self.initialContentId = contentId From 9aa3612512711ebeee020fbeed0846964a65dd59 Mon Sep 17 00:00:00 2001 From: Sam Davies Date: Tue, 7 Apr 2020 22:41:02 +0100 Subject: [PATCH 18/18] Bumping version number to 1.0.3 --- Emitron/Emitron.xcodeproj/project.pbxproj | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/Emitron/Emitron.xcodeproj/project.pbxproj b/Emitron/Emitron.xcodeproj/project.pbxproj index e66549d6..9d74de03 100644 --- a/Emitron/Emitron.xcodeproj/project.pbxproj +++ b/Emitron/Emitron.xcodeproj/project.pbxproj @@ -2263,7 +2263,6 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = KFCNEC27GU; INFOPLIST_FILE = emitronScreenshots/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2283,7 +2282,6 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = KFCNEC27GU; INFOPLIST_FILE = emitronScreenshots/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2303,7 +2301,6 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = KFCNEC27GU; INFOPLIST_FILE = emitronScreenshots/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2362,7 +2359,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -2384,12 +2381,11 @@ DEVELOPMENT_TEAM = KFCNEC27GU; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = "com.razeware.emitron.ios$(BUNDLE_ID_SUFFIX)"; PRODUCT_MODULE_NAME = Emitron; PRODUCT_NAME = raywenderlich; @@ -2406,7 +2402,6 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = KFCNEC27GU; INFOPLIST_FILE = emitronTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2427,7 +2422,6 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = KFCNEC27GU; INFOPLIST_FILE = emitronTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2448,7 +2442,6 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = KFCNEC27GU; INFOPLIST_FILE = emitronTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2513,7 +2506,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -2568,7 +2561,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -2591,12 +2584,11 @@ DEVELOPMENT_TEAM = KFCNEC27GU; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = "com.razeware.emitron.ios$(BUNDLE_ID_SUFFIX)"; PRODUCT_MODULE_NAME = Emitron; PRODUCT_NAME = raywenderlich; @@ -2618,12 +2610,11 @@ DEVELOPMENT_TEAM = KFCNEC27GU; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.2; + MARKETING_VERSION = 1.0.3; PRODUCT_BUNDLE_IDENTIFIER = "com.razeware.emitron.ios$(BUNDLE_ID_SUFFIX)"; PRODUCT_MODULE_NAME = Emitron; PRODUCT_NAME = raywenderlich;