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 diff --git a/Emitron/Emitron.xcodeproj/project.pbxproj b/Emitron/Emitron.xcodeproj/project.pbxproj index e7e7c4be..9d74de03 100644 --- a/Emitron/Emitron.xcodeproj/project.pbxproj +++ b/Emitron/Emitron.xcodeproj/project.pbxproj @@ -196,6 +196,9 @@ 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 */; }; + 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 +327,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 +533,9 @@ 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 = ""; }; + 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 +677,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 +1040,8 @@ 229F0AC323BB29230004DD4F /* Video */ = { isa = PBXGroup; children = ( - B6FC15BA22CB55160078CEDB /* VideoView.swift */, + 22C3F15624279692002812CB /* FullScreenVideoPlayerViewController.swift */, + 22C3F158242796EC002812CB /* FullScreenVideoPlayerRepresentable.swift */, ); path = Video; sourceTree = ""; @@ -1524,6 +1529,7 @@ B6D7DC5622C7B4A0006DD325 /* UI */ = { isa = PBXGroup; children = ( + 22C3F154242795A1002812CB /* PortraitHostingController.swift */, B6D7DC3022C79743006DD325 /* SceneDelegate.swift */, B62B9A7C22DF764900122CE8 /* App Root */, B6FC15AA22CB52430078CEDB /* Downloads */, @@ -1660,14 +1666,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 +2025,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 */, @@ -2075,7 +2082,6 @@ 22BE654F23F1F66E00717369 /* Comparable+Clamped.swift in Sources */, B6C4F3D222E6ECA50087ED10 /* CheckmarkView.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 +2107,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 +2170,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 */, @@ -2255,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", @@ -2275,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", @@ -2295,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", @@ -2354,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; @@ -2376,12 +2381,11 @@ DEVELOPMENT_TEAM = KFCNEC27GU; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; 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; @@ -2502,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; @@ -2557,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; @@ -2580,12 +2584,11 @@ DEVELOPMENT_TEAM = KFCNEC27GU; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; 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; @@ -2607,12 +2610,11 @@ DEVELOPMENT_TEAM = KFCNEC27GU; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = "Emitron/Support Files/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; 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; 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/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/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] } } 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 55401d59..7e609fcd 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" } } @@ -96,16 +99,18 @@ 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, videosService: VideosService, contentsService: ContentsService, - syncAction: SyncAction, + syncAction: SyncAction?, sessionController: SessionController = .current, dismissClosure: @escaping () -> Void = { }) { self.initialContentId = contentId @@ -189,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() } @@ -209,6 +215,38 @@ final class VideoPlaybackViewModel { self.handleTimeUpdate(time: time) } + 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 } + + self.player.rate = SettingsManager.current.playbackSpeed.rate + } + .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() @@ -337,6 +375,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 @@ -355,6 +394,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 @@ -376,6 +416,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 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? { 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) 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/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..6cee3cfc 100644 --- a/Emitron/Emitron/UI/SceneDelegate.swift +++ b/Emitron/Emitron/UI/SceneDelegate.swift @@ -71,29 +71,12 @@ 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 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() } } 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) 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/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) 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") } 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..b4c43ecc --- /dev/null +++ b/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift @@ -0,0 +1,105 @@ +// 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 = AVPlayerViewController() + viewController.player = viewModel?.player + viewController.delegate = self + present(viewController, animated: true) + viewModel?.play() + } + } +} + +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/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 - ) - ) - } - } - } -} 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 {