diff --git a/.github/workflows/appstore-upload.yml b/.github/workflows/appstore-upload.yml index 20c88886..3acb0e39 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 12 - run: sudo xcode-select --switch /Applications/Xcode_12.app + - name: Switch to Xcode 12.4 + run: sudo xcode-select --switch /Applications/Xcode_12.4.app - name: Update fastlane run: | cd Emitron @@ -29,12 +29,12 @@ jobs: scripts/download_s3.sh production > Emitron/Emitron/Configuration/secrets.production.xcconfig - name: Execute fastlane env: - FASTLANE_USER: engineering@razeware.com - FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }} - FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }} MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} SLACK_URL: ${{ secrets.SLACK_URL }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 5 FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 5 run: | diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 8d64db06..b360126b 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -9,8 +9,8 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v1 - - name: Switch to Xcode 12 - run: sudo xcode-select -s /Applications/Xcode_12.app + - name: Switch to Xcode 12.4 + run: sudo xcode-select -s /Applications/Xcode_12.4.app - name: Update fastlane run: | cd Emitron diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml index ba5023f4..88a7de0d 100644 --- a/.github/workflows/swiftlint.yml +++ b/.github/workflows/swiftlint.yml @@ -12,8 +12,6 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v1 - - name: Install Swiftlint - run: brew install swiftlint - name: Run Swiftlint run: swiftlint --config Emitron/.swiftlint.yml diff --git a/.github/workflows/testflight-beta.yml b/.github/workflows/testflight-beta.yml index 3f196650..536a3736 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 12 - run: sudo xcode-select --switch /Applications/Xcode_12.app + - name: Switch to Xcode 12.4 + run: sudo xcode-select --switch /Applications/Xcode_12.4.app - name: Update fastlane run: | cd Emitron @@ -29,12 +29,12 @@ jobs: scripts/download_s3.sh production > Emitron/Emitron/Configuration/secrets.production.xcconfig - name: Execute fastlane env: - FASTLANE_USER: engineering@razeware.com - FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }} - FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }} MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} SLACK_URL: ${{ secrets.SLACK_URL }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 5 FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 5 run: | diff --git a/.github/workflows/testflight-release.yml b/.github/workflows/testflight-release.yml index 8c0b9569..1016b740 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 12 - run: sudo xcode-select --switch /Applications/Xcode_12.app + - name: Switch to Xcode 12.4 + run: sudo xcode-select --switch /Applications/Xcode_12.4.app - name: Update fastlane run: | cd Emitron @@ -29,12 +29,12 @@ jobs: scripts/download_s3.sh production > Emitron/Emitron/Configuration/secrets.production.xcconfig - name: Execute fastlane env: - FASTLANE_USER: engineering@razeware.com - FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }} - FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }} MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} SLACK_URL: ${{ secrets.SLACK_URL }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 5 FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 5 run: | diff --git a/Emitron/.ruby-version b/Emitron/.ruby-version new file mode 100644 index 00000000..2c9b4ef4 --- /dev/null +++ b/Emitron/.ruby-version @@ -0,0 +1 @@ +2.7.3 diff --git a/Emitron/.swiftlint.yml b/Emitron/.swiftlint.yml index f5df941c..4d1ed600 100644 --- a/Emitron/.swiftlint.yml +++ b/Emitron/.swiftlint.yml @@ -51,7 +51,6 @@ opt_in_rules: - unowned_variable_capture - untyped_error_in_catch - unused_import - - unused_private_declaration - vertical_parameter_alignment_on_call - vertical_whitespace_closing_braces - xct_specific_matcher @@ -80,10 +79,6 @@ large_tuple: - 3 # warning - 4 # error -line_length: - - 150 # warning - - 200 # error - file_length: - 1200 # warning - 1500 # error diff --git a/Emitron/Emitron.xcodeproj/project.pbxproj b/Emitron/Emitron.xcodeproj/project.pbxproj index a0ea10e7..26b8aee3 100644 --- a/Emitron/Emitron.xcodeproj/project.pbxproj +++ b/Emitron/Emitron.xcodeproj/project.pbxproj @@ -258,7 +258,6 @@ 8BFCEF1F2341184A003FF72F /* PermissionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BFCEF1E2341184A003FF72F /* PermissionsRequest.swift */; }; 95A4513522E224B500D24E56 /* SessionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95A4513322E224B500D24E56 /* SessionController.swift */; }; B5678C62233668B100BCB8A1 /* CircleProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5678C61233668B100BCB8A1 /* CircleProgressBarView.swift */; }; - B595654423130E4C00A3FF44 /* CustomToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B595654323130E4C00A3FF44 /* CustomToggleView.swift */; }; B60612B922CCE352007FC852 /* Parameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60612B822CCE352007FC852 /* Parameters.swift */; }; B60FF7BE23000ABD00F36B32 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60FF7BD23000ABD00F36B32 /* CardView.swift */; }; B621DD5122F5EEC500A1F27A /* DataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B621DD5022F5EEC500A1F27A /* DataManager.swift */; }; @@ -598,7 +597,6 @@ 8BFCEF1E2341184A003FF72F /* PermissionsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsRequest.swift; sourceTree = ""; }; 95A4513322E224B500D24E56 /* SessionController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionController.swift; sourceTree = ""; }; B5678C61233668B100BCB8A1 /* CircleProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleProgressBarView.swift; sourceTree = ""; }; - B595654323130E4C00A3FF44 /* CustomToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomToggleView.swift; sourceTree = ""; }; B60612B822CCE352007FC852 /* Parameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parameters.swift; sourceTree = ""; }; B60FF7BD23000ABD00F36B32 /* CardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = ""; }; B621DD3622F5CB9A00A1F27A /* Attachment_Downloads.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = Attachment_Downloads.json; sourceTree = ""; }; @@ -1675,7 +1673,6 @@ isa = PBXGroup; children = ( B6C4F3D122E6ECA40087ED10 /* CheckmarkView.swift */, - B595654323130E4C00A3FF44 /* CustomToggleView.swift */, B66778AB2305D2D4003EEBAB /* MainButtonView.swift */, 8B283DEE23169A1E001F1B17 /* ProgressBarView.swift */, 229F0AC223BB27820004DD4F /* Content Detail */, @@ -2104,7 +2101,6 @@ B6D8EB2322CBA81200DE29AF /* DomainsService.swift in Sources */, 229935D023FE8C6F00D3D16A /* SettingsSelectable.swift in Sources */, B6D7DC4622C7AF60006DD325 /* UIFont+Extensions.swift in Sources */, - B595654423130E4C00A3FF44 /* CustomToggleView.swift in Sources */, 22D39FC823EC0AEA0058599D /* LoadingView.swift in Sources */, B6DF2FB622CA62E50081A3A3 /* JSONAPIDocument.swift in Sources */, 229F4A1623FE7ECB006E2DE6 /* SettingsToggleRow.swift in Sources */, @@ -2394,7 +2390,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "com.razeware.emitron.ios$(BUNDLE_ID_SUFFIX)"; PRODUCT_MODULE_NAME = Emitron; PRODUCT_NAME = raywenderlich; @@ -2599,7 +2595,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "com.razeware.emitron.ios$(BUNDLE_ID_SUFFIX)"; PRODUCT_MODULE_NAME = Emitron; PRODUCT_NAME = raywenderlich; @@ -2625,7 +2621,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "com.razeware.emitron.ios$(BUNDLE_ID_SUFFIX)"; PRODUCT_MODULE_NAME = Emitron; PRODUCT_NAME = raywenderlich; @@ -2710,7 +2706,7 @@ repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 5.0.0; + minimumVersion = 5.0.1; }; }; 22C0512B23A4CBB9004D1223 /* XCRemoteSwiftPackageReference "GRDBCombine" */ = { diff --git a/Emitron/Emitron.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Emitron/Emitron.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 15b300cf..2cc54c8a 100644 --- a/Emitron/Emitron.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Emitron/Emitron.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher", "state": { "branch": null, - "revision": "0efb20f022501327f4028aa9bf7cd4a0d390bea0", - "version": "5.15.2" + "revision": "1a0c2df04b31ed7aa318354f3583faea24f006fc", + "version": "5.15.8" } }, { @@ -51,8 +51,8 @@ "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON", "state": { "branch": null, - "revision": "2b6054efa051565954e1d2b9da831680026cd768", - "version": "5.0.0" + "revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", + "version": "5.0.1" } } ] diff --git a/Emitron/Emitron/AppDelegate.swift b/Emitron/Emitron/AppDelegate.swift index a83bbcdf..b9684e7d 100644 --- a/Emitron/Emitron/AppDelegate.swift +++ b/Emitron/Emitron/AppDelegate.swift @@ -76,7 +76,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { persistenceStore: persistenceStore, downloadService: downloadService ) - downloadService.startProcessing() + downloadService.startProcessing() } // MARK: UISceneSession Lifecycle diff --git a/Emitron/Emitron/Assets.xcassets/Artwork/Contents.json b/Emitron/Emitron/Assets.xcassets/Artwork/Contents.json index da4a164c..73c00596 100644 --- a/Emitron/Emitron/Assets.xcassets/Artwork/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Artwork/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Emitron/Emitron/Assets.xcassets/Icons/Contents.json b/Emitron/Emitron/Assets.xcassets/Icons/Contents.json index da4a164c..73c00596 100644 --- a/Emitron/Emitron/Assets.xcassets/Icons/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/Icons/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/materialIconSettings.png b/Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/materialIconSettings.png deleted file mode 100644 index 118b513f..00000000 Binary files a/Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/materialIconSettings.png and /dev/null differ diff --git a/Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/materialIconSettings@2x.png b/Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/materialIconSettings@2x.png deleted file mode 100644 index e25d27d3..00000000 Binary files a/Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/materialIconSettings@2x.png and /dev/null differ diff --git a/Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/materialIconSettings@3x.png b/Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/materialIconSettings@3x.png deleted file mode 100644 index 1c34721b..00000000 Binary files a/Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/materialIconSettings@3x.png and /dev/null differ diff --git a/Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/Contents.json b/Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/Contents.json similarity index 51% rename from Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/Contents.json rename to Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/Contents.json index 6e03d421..10ed1347 100644 --- a/Emitron/Emitron/Assets.xcassets/Icons/settings.imageset/Contents.json +++ b/Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/Contents.json @@ -1,23 +1,26 @@ { "images" : [ { + "filename" : "gear.png", "idiom" : "universal", - "filename" : "materialIconSettings.png", "scale" : "1x" }, { + "filename" : "gear@2x.png", "idiom" : "universal", - "filename" : "materialIconSettings@2x.png", "scale" : "2x" }, { + "filename" : "gear@3x.png", "idiom" : "universal", - "filename" : "materialIconSettings@3x.png", "scale" : "3x" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } -} \ No newline at end of file +} diff --git a/Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/gear.png b/Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/gear.png new file mode 100644 index 00000000..c8d07d46 Binary files /dev/null and b/Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/gear.png differ diff --git a/Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/gear@2x.png b/Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/gear@2x.png new file mode 100644 index 00000000..5befa3d4 Binary files /dev/null and b/Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/gear@2x.png differ diff --git a/Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/gear@3x.png b/Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/gear@3x.png new file mode 100644 index 00000000..82d08ace Binary files /dev/null and b/Emitron/Emitron/Assets.xcassets/TabBar/settings.imageset/gear@3x.png differ diff --git a/Emitron/Emitron/Constants.swift b/Emitron/Emitron/Constants.swift index ce933268..63e0c9cb 100644 --- a/Emitron/Emitron/Constants.swift +++ b/Emitron/Emitron/Constants.swift @@ -40,11 +40,10 @@ extension Int { } extension String { - static let clearAll = "Clear All" static let downloads = "Downloads" static let filters = "Filters" static let library = "Library" - static let loading = "Loading…" + static let loading = "Loading..." static let myTutorials = "My Tutorials" static let newest = "Newest" static let popularity = "Popularity" @@ -99,10 +98,6 @@ extension String { static let yes = "Yes" static let no = "No" // swiftlint:disable:this identifier_name - // MARK: Pull-to-refresh - static let pullToRefreshPullMessage = "Pull to refresh" - static let pullToRefreshLoadingMessage = "Loading…" - // MARK: Settings screens static let settingsPlaybackSpeedLabel = "Video Playback Speed" static let settingsWifiOnlyDownloadsLabel = "Downloads (WiFi only)" @@ -117,3 +112,7 @@ extension TimeInterval { // MARK: Video playback static let videoPlaybackOfflinePermissionsCheckPeriod: Self = 7 * 24 * 60 * 60 } + +enum LookupKey { + static let requestReview = "request" +} diff --git a/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift b/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift index 8846dd4a..3ee0a27e 100644 --- a/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/DynamicContentViewModel.swift @@ -40,7 +40,7 @@ final class DynamicContentViewModel: ObservableObject, DynamicContentDisplayable var state: DataState = .initial @Published var viewProgress: ContentViewProgressDisplayable = .notStarted @Published var downloadProgress: DownloadProgressDisplayable = .notDownloadable - @Published var bookmarked: Bool = false + @Published var bookmarked = false private var subscriptions = Set() private var downloadActionSubscriptions = Set() diff --git a/Emitron/Emitron/Data/ViewModels/TabViewModel.swift b/Emitron/Emitron/Data/ViewModels/TabViewModel.swift index 1beec936..29310766 100644 --- a/Emitron/Emitron/Data/ViewModels/TabViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/TabViewModel.swift @@ -32,6 +32,7 @@ enum MainTab: Hashable { case library case downloads case myTutorials + case settings } final class TabViewModel: ObservableObject { diff --git a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift index 5dd367b2..0d726654 100644 --- a/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift +++ b/Emitron/Emitron/Data/ViewModels/VideoPlaybackViewModel.swift @@ -72,9 +72,13 @@ extension VideoPlaybackViewModel { } } +extension Notification.Name { + static let requestReview = Notification.Name("requestReview") +} + final class VideoPlaybackViewModel { // Allow control of appearance and dismissal of the video view - var shouldShow: Bool = false + var shouldShow = false private let initialContentId: Int private let repository: Repository @@ -316,6 +320,7 @@ private extension VideoPlaybackViewModel { // Don't load the next one if we've already got another one ready to play guard player.items().last == currentItem else { return } // Preload the next video 10s from the end + if (currentItem.duration - time).seconds < 10 { enqueueNext() } @@ -323,7 +328,6 @@ private extension VideoPlaybackViewModel { func enqueueNext() { guard nextContentToEnqueueIndex < contentList.endIndex else { return } - enqueue(index: nextContentToEnqueueIndex) } @@ -380,7 +384,8 @@ private extension VideoPlaybackViewModel { if let download = state.download, download.state == .complete, let localURL = download.localURL { - let item = AVPlayerItem(url: localURL) + let asset = AVURLAsset(url: localURL) + let item = AVPlayerItem(asset: asset) self.addMetadata(from: state, to: item) self.addClosedCaptions(for: item) // Add it to the cache diff --git a/Emitron/Emitron/Downloads/DownloadProcessor.swift b/Emitron/Emitron/Downloads/DownloadProcessor.swift index 9402c102..1a0d9eb5 100644 --- a/Emitron/Emitron/Downloads/DownloadProcessor.swift +++ b/Emitron/Emitron/Downloads/DownloadProcessor.swift @@ -28,6 +28,7 @@ import Foundation import Combine +import AVFoundation protocol DownloadProcessorModel { var id: UUID { get } @@ -41,8 +42,6 @@ protocol DownloadProcessorDelegate: AnyObject { func downloadProcessor(_ processor: DownloadProcessor, downloadWithId downloadId: UUID, didUpdateProgress progress: Double) func downloadProcessor(_ processor: DownloadProcessor, didFinishDownloadWithId downloadId: UUID) func downloadProcessor(_ processor: DownloadProcessor, didCancelDownloadWithId downloadId: UUID) - func downloadProcessor(_ processor: DownloadProcessor, didPauseDownloadWithId downloadId: UUID) - func downloadProcessor(_ processor: DownloadProcessor, didResumeDownloadWithId downloadId: UUID) func downloadProcessor(_ processor: DownloadProcessor, downloadWithId downloadId: UUID, didFailWithError error: Error) } @@ -58,6 +57,18 @@ private extension URLSessionDownloadTask { } } +private extension AVAssetDownloadTask { + var downloadId: UUID? { + get { + guard let taskDescription = taskDescription else { return .none } + return UUID(uuidString: taskDescription) + } + set { + taskDescription = newValue?.uuidString ?? "" + } + } +} + enum DownloadProcessorError: Error { case invalidArguments case unknownDownload @@ -66,17 +77,21 @@ enum DownloadProcessorError: Error { // Manage a list of files to download—either queued, in progresss, paused or failed. final class DownloadProcessor: NSObject { static let sessionIdentifier = "com.razeware.emitron.DownloadProcessor" + static let sdBitrate = 250_000 + private var downloadQuality: Attachment.Kind { + SettingsManager.current.downloadQuality + } - private lazy var session: URLSession = { + private lazy var session: AVAssetDownloadURLSession = { let config = URLSessionConfiguration.background(withIdentifier: DownloadProcessor.sessionIdentifier) // Uncommenting this causes the download task to fail with POSIX 22. But seemingly only with // Vimeo URLs. So that's handy. - //config.isDiscretionary = true + // config.isDiscretionary = true config.sessionSendsLaunchEvents = true - return URLSession(configuration: config, delegate: self, delegateQueue: .none) + return AVAssetDownloadURLSession(configuration: config, assetDownloadDelegate: self, delegateQueue: .none) }() var backgroundSessionCompletionHandler: (() -> Void)? - private var currentDownloads = [URLSessionDownloadTask]() + private var currentDownloads = [AVAssetDownloadTask]() private var throttleList = [UUID: Double]() weak var delegate: DownloadProcessorDelegate! @@ -89,8 +104,13 @@ final class DownloadProcessor: NSObject { extension DownloadProcessor { func add(download: DownloadProcessorModel) throws { guard let remoteURL = download.remoteURL else { throw DownloadProcessorError.invalidArguments } - - let downloadTask = session.downloadTask(with: remoteURL) + let hlsAsset = AVURLAsset(url: remoteURL) + var options: [String: Any]? + if downloadQuality == .sdVideoFile { + options = [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: DownloadProcessor.sdBitrate] + } + guard let downloadTask = session.makeAssetDownloadTask(asset: hlsAsset, assetTitle: "\(download.id))", assetArtworkData: nil, options: options) else { return } + downloadTask.downloadId = download.id downloadTask.resume() @@ -99,22 +119,6 @@ extension DownloadProcessor { delegate.downloadProcessor(self, didStartDownloadWithId: download.id) } - func pauseDownload(_ download: DownloadProcessorModel) throws { - guard let downloadTask = currentDownloads.first(where: { $0.downloadId == download.id }) else { throw DownloadProcessorError.unknownDownload } - - downloadTask.suspend() - - delegate.downloadProcessor(self, didPauseDownloadWithId: download.id) - } - - func resumeDownload(_ download: DownloadProcessorModel) throws { - guard let downloadTask = currentDownloads.first(where: { $0.downloadId == download.id }) else { throw DownloadProcessorError.unknownDownload } - - downloadTask.resume() - - delegate.downloadProcessor(self, didResumeDownloadWithId: download.id) - } - func cancelDownload(_ download: DownloadProcessorModel) throws { guard let downloadTask = currentDownloads.first(where: { $0.downloadId == download.id }) else { throw DownloadProcessorError.unknownDownload } @@ -135,13 +139,15 @@ extension DownloadProcessor { } extension DownloadProcessor { - private func getDownloadTasksFromSession() -> [URLSessionDownloadTask] { - var tasks = [URLSessionDownloadTask]() + private func getDownloadTasksFromSession() -> [AVAssetDownloadTask] { + var tasks = [AVAssetDownloadTask]() // Use a semaphore to make an async call synchronous // --There's no point in trying to complete instantiating this class without this list. let semaphore = DispatchSemaphore(value: 0) - session.getTasksWithCompletionHandler { _, _, downloadTasks in - tasks = downloadTasks + session.getAllTasks { downloadTasks in + + let myTasks = downloadTasks as! [AVAssetDownloadTask] + tasks = myTasks semaphore.signal() } @@ -155,6 +161,47 @@ extension DownloadProcessor { } } +extension DownloadProcessor: AVAssetDownloadDelegate { + + func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) { + + guard let downloadId = assetDownloadTask.downloadId else { return } + + var percentComplete = 0.0 + for value in loadedTimeRanges { + let loadedTimeRange: CMTimeRange = value.timeRangeValue + percentComplete += CMTimeGetSeconds(loadedTimeRange.duration) / CMTimeGetSeconds(timeRangeExpectedToLoad.duration) + } + + if let lastReportedProgress = throttleList[downloadId], + abs(percentComplete - lastReportedProgress) < 0.02 { + // Less than a 2% change—it's a no-op + return + } + throttleList[downloadId] = percentComplete + delegate.downloadProcessor(self, downloadWithId: downloadId, didUpdateProgress: percentComplete) + } + + func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { + + guard let downloadId = assetDownloadTask.downloadId, + let delegate = delegate else { return } + + let download = delegate.downloadProcessor(self, downloadModelForDownloadWithId: downloadId) + guard let localURL = download?.localURL else { return } + + let fileManager = FileManager.default + do { + if fileManager.fileExists(atPath: localURL.path) { + try fileManager.removeItem(at: localURL) + } + try fileManager.moveItem(at: location, to: localURL) + } catch { + delegate.downloadProcessor(self, downloadWithId: downloadId, didFailWithError: error) + } + } +} + extension DownloadProcessor: URLSessionDownloadDelegate { // When the background session has finished sending us events, we can tell the system we're done. func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { @@ -199,44 +246,30 @@ extension DownloadProcessor: URLSessionDownloadDelegate { // Use this to handle and client-side download errors func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let downloadTask = task as? URLSessionDownloadTask, let downloadId = downloadTask.downloadId else { return } - + + guard let downloadTask = task as? AVAssetDownloadTask, let downloadId = downloadTask.downloadId else { return } + if let error = error as NSError? { let cancellationReason = (error.userInfo[NSURLErrorBackgroundTaskCancelledReasonKey] as? NSNumber)?.intValue if cancellationReason == NSURLErrorCancelledReasonUserForceQuitApplication || cancellationReason == NSURLErrorCancelledReasonBackgroundUpdatesDisabled { // The download was cancelled for technical reasons, but we might be able to restart it... - var newTask: URLSessionDownloadTask? - if let resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data { - newTask = session.downloadTask(withResumeData: resumeData) - } else { - let download = delegate.downloadProcessor(self, downloadModelForDownloadWithId: downloadId) - if let remoteURL = download?.remoteURL { - newTask = session.downloadTask(with: remoteURL) - } - } - if let newTask = newTask { - newTask.downloadId = downloadId - newTask.resume() - currentDownloads.append(newTask) - delegate.downloadProcessor(self, didStartDownloadWithId: downloadId) - } - + currentDownloads.removeAll { $0 == downloadTask } } else if error.code == NSURLErrorCancelled { // User-requested cancellation currentDownloads.removeAll { $0 == downloadTask } - + delegate.downloadProcessor(self, didCancelDownloadWithId: downloadId) } else { // Unknown error currentDownloads.removeAll { $0 == downloadTask } - + delegate.downloadProcessor(self, downloadWithId: downloadId, didFailWithError: error) } } else { // Success! currentDownloads.removeAll { $0 == downloadTask } - + delegate.downloadProcessor(self, didFinishDownloadWithId: downloadId) } } diff --git a/Emitron/Emitron/Downloads/DownloadService.swift b/Emitron/Emitron/Downloads/DownloadService.swift index 5804e607..8d01c6bc 100644 --- a/Emitron/Emitron/Downloads/DownloadService.swift +++ b/Emitron/Emitron/Downloads/DownloadService.swift @@ -30,11 +30,6 @@ import Combine import Foundation import Network -enum DownloadServiceError: Error { - case unableToCancelDownload - case unableToDeleteDownload -} - final class DownloadService { enum Status { case active @@ -304,28 +299,28 @@ extension DownloadService { } // Use the video service to request the URLs - videosService.getVideoDownload(for: videoId) { [weak self] result in + videosService.getVideoStreamDownload(for: videoId) { [weak self] result in // Ensure we're still around guard let self = self else { return } var download = downloadQueueItem.download - + switch result { case .failure(let error): Failure .downloadService(from: "requestDownloadURL", reason: "Unable to obtain download URLs: \(error)") .log() - case .success(let attachments): - download.remoteURL = attachments.first { $0.kind == self.downloadQuality }?.url + case .success(let attachment): + download.remoteURL = attachment.url download.lastValidatedAt = Date() download.state = .readyForDownload } - + // Update the state if required if download.remoteURL == nil { download.state = .error } - + // Commit the changes do { try self.persistenceStore.update(download: download) @@ -337,7 +332,6 @@ extension DownloadService { self.transitionDownload(withID: download.id, to: .failed) } } - // Move it on through the state machine transitionDownload(withID: downloadQueueItem.download.id, to: .urlRequested) } @@ -361,7 +355,7 @@ extension DownloadService { } // Generate filename - let filename = "\(videoId).mp4" + let filename = "\(videoId).m3u8" // Save local URL and filename var download = downloadQueueItem.download @@ -504,14 +498,6 @@ extension DownloadService: DownloadProcessorDelegate { } } - func downloadProcessor(_ processor: DownloadProcessor, didPauseDownloadWithId downloadId: UUID) { - transitionDownload(withID: downloadId, to: .paused) - } - - func downloadProcessor(_ processor: DownloadProcessor, didResumeDownloadWithId downloadId: UUID) { - transitionDownload(withID: downloadId, to: .inProgress) - } - func downloadProcessor(_ processor: DownloadProcessor, downloadWithId downloadId: UUID, didFailWithError error: Error) { transitionDownload(withID: downloadId, to: .error) Failure @@ -537,18 +523,6 @@ extension DownloadService { .downloadList() .eraseToAnyPublisher() } - - func downloadedContentSummary(for contentId: Int) -> AnyPublisher { - persistenceStore - .downloadContentSummary(for: contentId) - .eraseToAnyPublisher() - } - - func contentSummaries(for contentIds: [Int]) -> AnyPublisher<[ContentSummaryState], Error> { - persistenceStore - .downloadContentSummary(for: contentIds) - .eraseToAnyPublisher() - } } // MARK: - Wifi Status Handling diff --git a/Emitron/Emitron/Guardpost/Guardpost.swift b/Emitron/Emitron/Guardpost/Guardpost.swift index c2f2fec2..486d32ba 100644 --- a/Emitron/Emitron/Guardpost/Guardpost.swift +++ b/Emitron/Emitron/Guardpost/Guardpost.swift @@ -127,7 +127,10 @@ public class Guardpost { authSession?.presentationContextProvider = presentationContextDelegate // This will prevent sharing cookies with Safari, which means no auto-login // However, it also means that you can actually log out, which is good, I guess. + #if (!DEBUG) authSession?.prefersEphemeralWebBrowserSession = true + #endif + authSession?.start() } diff --git a/Emitron/Emitron/Models/Attachment.swift b/Emitron/Emitron/Models/Attachment.swift index 8627a471..f28c283e 100644 --- a/Emitron/Emitron/Models/Attachment.swift +++ b/Emitron/Emitron/Models/Attachment.swift @@ -80,6 +80,11 @@ struct Attachment: Codable { static var selectableCases: [Attachment.Kind] { [.sdVideoFile, .hdVideoFile] } + + // Added hash(into: ) funciton because of this bug: https://bugs.swift.org/browse/SR-13851 + func hash(into hasher: inout Hasher) { + hasher.combine(display) + } } var id: Int diff --git a/Emitron/Emitron/Models/ContentSubscriptionPlan.swift b/Emitron/Emitron/Models/ContentSubscriptionPlan.swift index 98bc525c..74d565de 100644 --- a/Emitron/Emitron/Models/ContentSubscriptionPlan.swift +++ b/Emitron/Emitron/Models/ContentSubscriptionPlan.swift @@ -30,10 +30,6 @@ enum ContentSubscriptionPlan: Int, Codable { case beginner case professional - init(_ pro: Bool) { - self = pro ? .professional : .beginner - } - var displayString: String { switch self { case .beginner: diff --git a/Emitron/Emitron/Models/PlaybackSpeed.swift b/Emitron/Emitron/Models/PlaybackSpeed.swift index 2977cb26..8e451958 100644 --- a/Emitron/Emitron/Models/PlaybackSpeed.swift +++ b/Emitron/Emitron/Models/PlaybackSpeed.swift @@ -28,7 +28,9 @@ enum PlaybackSpeed: Int, CaseIterable, SettingsSelectable { case half + case threeQuarters case standard + case onePointTwoFive case onePointFive case double @@ -40,8 +42,12 @@ enum PlaybackSpeed: Int, CaseIterable, SettingsSelectable { switch self { case .half: return 0.5 + case .threeQuarters: + return 0.75 case .standard: return 1.0 + case .onePointTwoFive: + return 1.25 case .onePointFive: return 1.5 case .double: @@ -53,8 +59,12 @@ enum PlaybackSpeed: Int, CaseIterable, SettingsSelectable { switch self { case .half: return "0.5x" + case .threeQuarters: + return "0.75x" case .standard: return "1.0x" + case .onePointTwoFive: + return "1.25x" case .onePointFive: return "1.5x" case .double: @@ -66,3 +76,10 @@ enum PlaybackSpeed: Int, CaseIterable, SettingsSelectable { allCases } } + +extension PlaybackSpeed { + // Added hash(into: ) funciton because of this bug: https://bugs.swift.org/browse/SR-13851 + func hash(into hasher: inout Hasher) { + hasher.combine(display) + } +} diff --git a/Emitron/Emitron/Models/SettingsOption.swift b/Emitron/Emitron/Models/SettingsOption.swift index 9b33941d..deb31bb2 100644 --- a/Emitron/Emitron/Models/SettingsOption.swift +++ b/Emitron/Emitron/Models/SettingsOption.swift @@ -67,11 +67,11 @@ enum SettingsOption: Int, Identifiable, CaseIterable { case .playbackSpeed: return PlaybackSpeed.allCases.map(\.display) case .wifiOnlyDownloads: - return ["Yes", "No"] + return [String.yes, String.no] case .downloadQuality: return Attachment.Kind.downloads.map(\.display) case .closedCaptionOn: - return ["Yes", "No"] + return [String.yes, String.no] } } diff --git a/Emitron/Emitron/Networking/Requests/Parameters.swift b/Emitron/Emitron/Networking/Requests/Parameters.swift index e68c88ca..ca975512 100644 --- a/Emitron/Emitron/Networking/Requests/Parameters.swift +++ b/Emitron/Emitron/Networking/Requests/Parameters.swift @@ -162,7 +162,7 @@ enum ParameterFilterValue { } } -//sort=-released_at; reversechronological order +// sort=-released_at; reversechronological order enum ParameterSortValue: String, Codable { case popularity = "popularity" case releasedAt = "released_at" diff --git a/Emitron/Emitron/Networking/Requests/Request.swift b/Emitron/Emitron/Networking/Requests/Request.swift index be99c7b2..350ce208 100644 --- a/Emitron/Emitron/Networking/Requests/Request.swift +++ b/Emitron/Emitron/Networking/Requests/Request.swift @@ -53,15 +53,3 @@ extension Request { var method: HTTPMethod { .GET } var body: Data? { nil } } - -enum RequestError: Error { - case responseMissingRequiredMeta(field: String?) - - var localizedDescription: String { - let prefix = "RequestError::" - switch self { - case .responseMissingRequiredMeta(field: let field): - return "\(prefix)ResponseMissingRequiredMeta: [Field: \(field ?? "UNKNOWN")]" - } - } -} diff --git a/Emitron/Emitron/Networking/Requests/VideosRequest.swift b/Emitron/Emitron/Networking/Requests/VideosRequest.swift index 51938daf..3d7455c5 100644 --- a/Emitron/Emitron/Networking/Requests/VideosRequest.swift +++ b/Emitron/Emitron/Networking/Requests/VideosRequest.swift @@ -56,13 +56,12 @@ struct StreamVideoRequest: Request { } } -struct DownloadVideoRequest: Request { - // It contains two Attachment objects, one for the HD file and one for the SD file. - typealias Response = [Attachment] +struct DownloadStreamVideoRequest: Request { + typealias Response = Attachment // MARK: - Properties var method: HTTPMethod { .GET } - var path: String { "/videos/\(id)/download" } + var path: String { "/videos/\(id)/stream" } var additionalHeaders: [String: String] = [:] var body: Data? { nil } @@ -70,9 +69,16 @@ struct DownloadVideoRequest: Request { let id: Int // MARK: - Internal - func handle(response: Data) throws -> [Attachment] { + func handle(response: Data) throws -> Attachment { let json = try JSON(data: response) let doc = JSONAPIDocument(json) - return try doc.data.map { try AttachmentAdapter.process(resource: $0) } + let attachments = try doc.data.map { try AttachmentAdapter.process(resource: $0) } + + guard let attachment = attachments.first, + attachments.count == 1 else { + throw RWAPIError.responseHasIncorrectNumberOfElements + } + + return attachment } } diff --git a/Emitron/Emitron/Networking/Services/Service.swift b/Emitron/Emitron/Networking/Services/Service.swift index b13df6cd..3c21003a 100644 --- a/Emitron/Emitron/Networking/Services/Service.swift +++ b/Emitron/Emitron/Networking/Services/Service.swift @@ -85,7 +85,6 @@ class Service { func prepare(request: R, parameters: [Parameter]?) -> URLRequest? { let pathURL = networkClient.environment.baseURL.appendingPathComponent(request.path) - var components = URLComponents(url: pathURL, resolvingAgainstBaseURL: false) diff --git a/Emitron/Emitron/Networking/Services/VideosService.swift b/Emitron/Emitron/Networking/Services/VideosService.swift index 25f223ed..37278619 100644 --- a/Emitron/Emitron/Networking/Services/VideosService.swift +++ b/Emitron/Emitron/Networking/Services/VideosService.swift @@ -37,9 +37,9 @@ class VideosService: Service { completion: completion) } - func getVideoDownload(for id: Int, - completion: @escaping (_ response: Result) -> Void) { - let request = DownloadVideoRequest(id: id) + func getVideoStreamDownload(for id: Int, + completion: @escaping (_ response: Result) -> Void) { + let request = DownloadStreamVideoRequest(id: id) makeAndProcessRequest(request: request, completion: completion) } diff --git a/Emitron/Emitron/Networking/Services/WatchStatsService.swift b/Emitron/Emitron/Networking/Services/WatchStatsService.swift index 74fd4a7c..04107d0b 100644 --- a/Emitron/Emitron/Networking/Services/WatchStatsService.swift +++ b/Emitron/Emitron/Networking/Services/WatchStatsService.swift @@ -27,8 +27,6 @@ // THE SOFTWARE. class WatchStatsService: Service { - typealias Provider = (RWAPI) -> WatchStatsService - func update(watchStats: [WatchStat], completion: @escaping (_ response: Result) -> Void) { let request = WatchStatsUpdateRequest(watchStats: watchStats) diff --git a/Emitron/Emitron/Persistence/PersistenceStore+Downloads.swift b/Emitron/Emitron/Persistence/PersistenceStore+Downloads.swift index 57db7437..3ea6530a 100644 --- a/Emitron/Emitron/Persistence/PersistenceStore+Downloads.swift +++ b/Emitron/Emitron/Persistence/PersistenceStore+Downloads.swift @@ -51,43 +51,6 @@ extension PersistenceStore { } extension PersistenceStore { - func downloadContentSummary(for contentId: Int) -> DatabasePublishers.Value { - ValueObservation.tracking { db -> ContentSummaryState? in - let request = Content - .filter(key: contentId) - .including(all: Content.domains) - .including(all: Content.categories) - .including(optional: Content.parentContent) - - return try ContentSummaryState.fetchOne(db, request) - } - .publisher(in: db) - } - - func downloadContentSummary(for contentIds: [Int]) -> DatabasePublishers.Value<[ContentSummaryState]> { - ValueObservation.tracking { db -> [ContentSummaryState] in - let request = Content - .filter(keys: contentIds) - .including(all: Content.domains) - .including(all: Content.categories) - .including(optional: Content.parentContent) - - return try ContentSummaryState.fetchAll(db, request) - } - .publisher(in: db) - } -} - -extension PersistenceStore { - func downloads(for contentIds: [Int]) -> DatabasePublishers.Value<[Download]> { - ValueObservation.tracking { db -> [Download] in - let request = Download - .filter(contentIds.contains(Download.Columns.contentId)) - return try Download.fetchAll(db, request) - } - .publisher(in: db) - } - func download(for contentId: Int) -> DatabasePublishers.Value { ValueObservation.tracking { db -> Download? in let request = Download @@ -374,14 +337,7 @@ extension PersistenceStore { } } } - - /// Delete all the downloads in the database - func deleteDownloads() throws { - _ = try db.write { db in - try Download.deleteAll(db) - } - } - + /// Save the entire graph of models to supprt this ContentDeailsModel /// - Parameter contentPersistableState: The model to persist—from the DataCache. func persistContentGraph(for contentPersistableState: ContentPersistableState, contentLookup: ContentLookup? = nil) -> Future { diff --git a/Emitron/Emitron/Sessions/SessionController.swift b/Emitron/Emitron/Sessions/SessionController.swift index 6130f0ba..ade8a452 100644 --- a/Emitron/Emitron/Sessions/SessionController.swift +++ b/Emitron/Emitron/Sessions/SessionController.swift @@ -144,8 +144,6 @@ class SessionController: NSObject, UserModelController, ObservablePrePostFactoOb .log() case .success(let user): self.user = user - print(user) - Event .login(from: "SessionController") .log() diff --git a/Emitron/Emitron/Styleguide/Font+Extensions.swift b/Emitron/Emitron/Styleguide/Font+Extensions.swift index 6745dfd3..cf8355bc 100644 --- a/Emitron/Emitron/Styleguide/Font+Extensions.swift +++ b/Emitron/Emitron/Styleguide/Font+Extensions.swift @@ -88,9 +88,15 @@ extension Font { } // Can't have bold Font's - static var uiButtonLabel: Font { + static var uiButtonLabelLarge: Font { Font.system(size: UIFontMetrics.default.scaledValue(for: 17.0)).bold() } + static var uiButtonLabelMedium: Font { + Font.system(size: UIFontMetrics.default.scaledValue(for: 15)).weight(.bold) + } + static var uiButtonLabelSmall: Font { + Font.system(size: UIFontMetrics.default.scaledValue(for: 13.0)).weight(.semibold) + } static var uiBodyCustom: Font { Font.system(size: UIFontMetrics.default.scaledValue(for: 15.0)) } @@ -100,9 +106,6 @@ extension Font { static var uiLabel: Font { Font.system(size: UIFontMetrics.default.scaledValue(for: 16.0)) } - static var uiButtonLabelSmall: Font { - Font.system(size: UIFontMetrics.default.scaledValue(for: 13.0)).weight(.semibold) - } static var uiFootnote: Font { Font.footnote } diff --git a/Emitron/Emitron/Styleguide/UIFont+Extensions.swift b/Emitron/Emitron/Styleguide/UIFont+Extensions.swift index 3460c8ad..a44a730a 100644 --- a/Emitron/Emitron/Styleguide/UIFont+Extensions.swift +++ b/Emitron/Emitron/Styleguide/UIFont+Extensions.swift @@ -32,48 +32,7 @@ extension UIFont { static var uiLargeTitle: UIFont { UIFont(name: "Bitter-Bold", size: 34.0)! } - static var uiTitle1: UIFont { - UIFont(name: "Bitter-Bold", size: 28.0)! - } - static var uiTitle2: UIFont { - UIFont(name: "Bitter-Bold", size: 22.0)! - } - static var uiTitle3: UIFont { - UIFont(name: "Bitter-Bold", size: 20.0)! - } - static var uiTitle4: UIFont { - UIFont(name: "Bitter-Bold", size: 17.0)! - } static var uiHeadline: UIFont { UIFont(name: "Bitter-Regular", size: 17.0)! } - - static var uiNumberBox: UIFont { - UIFont(name: "Bitter-Bold", size: 13.0)! - } - - static var uiBodyAppleDefault: UIFont { - UIFont.systemFont(ofSize: 17.0, weight: .regular) - } - static var uiButtonLabel: UIFont { - UIFont.systemFont(ofSize: 15.0, weight: .bold) - } - static var uiBodyCustom: UIFont { - UIFont.systemFont(ofSize: 15.0, weight: .regular) - } - static var uiLabel: UIFont { - UIFont.systemFont(ofSize: 14.0, weight: .semibold) - } - static var uiButtonLabelSmall: UIFont { - UIFont.systemFont(ofSize: 13.0, weight: .semibold) - } - static var uiFootnote: UIFont { - UIFont.systemFont(ofSize: 13.0, weight: .regular) - } - static var uiCaption: UIFont { - UIFont.systemFont(ofSize: 12.0, weight: .regular) - } - static var uiUppercase: UIFont { - UIFont.systemFont(ofSize: 11.0, weight: .medium) - } } diff --git a/Emitron/Emitron/UI/App Root/MainView.swift b/Emitron/Emitron/UI/App Root/MainView.swift index acb5212b..cf69a5d2 100644 --- a/Emitron/Emitron/UI/App Root/MainView.swift +++ b/Emitron/Emitron/UI/App Root/MainView.swift @@ -27,16 +27,35 @@ // THE SOFTWARE. import SwiftUI +import StoreKit struct MainView: View { @EnvironmentObject var sessionController: SessionController @EnvironmentObject var dataManager: DataManager private let tabViewModel = TabViewModel() - + private let notification = NotificationCenter.default.publisher(for: .requestReview) + var body: some View { - contentView - .background(Color.backgroundColor) - .overlay(MessageBarView(messageBus: MessageBus.current), alignment: .bottom) + ZStack { + contentView + .background(Color.backgroundColor) + .overlay(MessageBarView(messageBus: MessageBus.current), alignment: .bottom) + .onReceive(notification) { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + makeReviewRequest() + } + } + } + } + + private func makeReviewRequest() { + if #available(iOS 14.0, *) { + if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { + SKStoreReviewController.requestReview(in: scene) + } + } else { + SKStoreReviewController.requestReview() + } } } @@ -61,6 +80,8 @@ private extension MainView { contentScreen: .downloads(permitted: sessionController.user?.canDownload ?? false), downloadRepository: dataManager.downloadRepository ) + + let settingsView = SettingsView() switch sessionController.sessionState { case .online : @@ -80,13 +101,15 @@ private extension MainView { TabNavView( libraryView: libraryView, myTutorialsView: myTutorialsView, - downloadsView: downloadsView + downloadsView: downloadsView, + settingsView: settingsView ) .environmentObject(tabViewModel) case .offline: TabNavView(libraryView: OfflineView(), myTutorialsView: OfflineView(), - downloadsView: downloadsView) + downloadsView: downloadsView, + settingsView: settingsView) .environmentObject(tabViewModel) case .unknown: LoadingView() diff --git a/Emitron/Emitron/UI/App Root/PermissionsLoadingView.swift b/Emitron/Emitron/UI/App Root/PermissionsLoadingView.swift index b17b615b..c1acd3f3 100644 --- a/Emitron/Emitron/UI/App Root/PermissionsLoadingView.swift +++ b/Emitron/Emitron/UI/App Root/PermissionsLoadingView.swift @@ -30,7 +30,7 @@ import SwiftUI struct PermissionsLoadingView: View { @EnvironmentObject var sessionController: SessionController - @State var showLogoutAlert: Bool = false + @State var showLogoutAlert = false var body: some View { LoadingView() diff --git a/Emitron/Emitron/UI/App Root/TabNavView.swift b/Emitron/Emitron/UI/App Root/TabNavView.swift index 875f2d0f..7a6b05c7 100644 --- a/Emitron/Emitron/UI/App Root/TabNavView.swift +++ b/Emitron/Emitron/UI/App Root/TabNavView.swift @@ -31,12 +31,14 @@ import SwiftUI struct TabNavView< LibraryView: View, MyTutorialsView: View, - DownloadsView: View + DownloadsView: View, + SettingsView: View >: View { @EnvironmentObject var tabViewModel: TabViewModel let libraryView: LibraryView let myTutorialsView: MyTutorialsView let downloadsView: DownloadsView + let settingsView: SettingsView var body: some View { TabView(selection: $tabViewModel.selectedTab) { @@ -70,6 +72,15 @@ struct TabNavView< .tag(MainTab.myTutorials) .navigationViewStyle(StackNavigationViewStyle()) .accessibility(label: .init(String.myTutorials)) + + NavigationView { settingsView } + .tabItem { + Text(String.settings) + Image("settings") + } + .tag(MainTab.settings) + .navigationViewStyle(StackNavigationViewStyle()) + .accessibility(label: .init(String.settings)) } .accentColor(.accent) } @@ -80,7 +91,8 @@ struct TabNavView_Previews: PreviewProvider { TabNavView( libraryView: Text("LIBRARY"), myTutorialsView: Text("MY TUTORIALS"), - downloadsView: Text("DOWNLOADS") + downloadsView: Text("DOWNLOADS"), + settingsView: Text("SETTINGS") ).environmentObject(TabViewModel()) } } diff --git a/Emitron/Emitron/UI/Empty States/LoadingView.swift b/Emitron/Emitron/UI/Empty States/LoadingView.swift index c26073ee..113b4922 100644 --- a/Emitron/Emitron/UI/Empty States/LoadingView.swift +++ b/Emitron/Emitron/UI/Empty States/LoadingView.swift @@ -33,7 +33,7 @@ struct LoadingView: View { VStack { ActivityIndicator() .padding([.bottom], 10) - Text("Loading...") + Text(String.loading) .font(.uiHeadline) } } diff --git a/Emitron/Emitron/UI/Library/Filtering/FiltersHeaderView.swift b/Emitron/Emitron/UI/Library/Filtering/FiltersHeaderView.swift index af576502..3507a101 100644 --- a/Emitron/Emitron/UI/Library/Filtering/FiltersHeaderView.swift +++ b/Emitron/Emitron/UI/Library/Filtering/FiltersHeaderView.swift @@ -42,7 +42,7 @@ struct FiltersHeaderView: View { var filterGroup: FilterGroup @ObservedObject var filters: Filters - @State var isExpanded: Bool = false + @State var isExpanded = false var body: some View { VStack { diff --git a/Emitron/Emitron/UI/Library/Filtering/FiltersView.swift b/Emitron/Emitron/UI/Library/Filtering/FiltersView.swift index 3bd27ea1..2db7c86f 100644 --- a/Emitron/Emitron/UI/Library/Filtering/FiltersView.swift +++ b/Emitron/Emitron/UI/Library/Filtering/FiltersView.swift @@ -45,7 +45,7 @@ struct FiltersView: View { Spacer() - Text("Filters") + Text(String.filters) .font(.uiTitle5) .foregroundColor(.titleText) diff --git a/Emitron/Emitron/UI/Library/LibraryView.swift b/Emitron/Emitron/UI/Library/LibraryView.swift index fdada262..1c371293 100644 --- a/Emitron/Emitron/UI/Library/LibraryView.swift +++ b/Emitron/Emitron/UI/Library/LibraryView.swift @@ -38,7 +38,7 @@ private extension CGFloat { struct LibraryView: View { @ObservedObject var filters: Filters @ObservedObject var libraryRepository: LibraryRepository - @State var filtersPresented: Bool = false + @State var filtersPresented = false var body: some View { contentView diff --git a/Emitron/Emitron/UI/My Tutorials/MyTutorialsView.swift b/Emitron/Emitron/UI/My Tutorials/MyTutorialsView.swift index 315b5198..0b2a506f 100644 --- a/Emitron/Emitron/UI/My Tutorials/MyTutorialsView.swift +++ b/Emitron/Emitron/UI/My Tutorials/MyTutorialsView.swift @@ -95,26 +95,6 @@ extension MyTutorialView: View { var body: some View { contentView .navigationBarTitle(String.myTutorials) - .navigationBarItems(trailing: - SwiftUI.Group { - Button(action: { - settingsPresented = true - }) { - Image("settings") - .foregroundColor(.iconButton) - } - }) - .sheet(isPresented: $settingsPresented) { - SettingsView(showLogoutButton: true) - // We have to pass this cos the sheet is in a different view hierarchy, so doesn't 'inherit' it. - .environmentObject(sessionController) - .environmentObject(tabViewModel) - } - .onDisappear { - reloadProgression = true - reloadCompleted = true - reloadBookmarks = true - } } } diff --git a/Emitron/Emitron/UI/SceneDelegate.swift b/Emitron/Emitron/UI/SceneDelegate.swift index 9b7f6cf2..f2802a5a 100644 --- a/Emitron/Emitron/UI/SceneDelegate.swift +++ b/Emitron/Emitron/UI/SceneDelegate.swift @@ -70,7 +70,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let mainView = MainView() .environmentObject(sessionController) .environmentObject(dataManager) - + if NSUbiquitousKeyValueStore.default.object(forKey: LookupKey.requestReview) == nil { + NSUbiquitousKeyValueStore.default.set(Date().timeIntervalSince1970, forKey: LookupKey.requestReview) + } + window.rootViewController = PortraitHostingController(rootView: mainView) self.window = window window.rootViewController?.view.backgroundColor = .backgroundColor diff --git a/Emitron/Emitron/UI/Settings/Licenses/LicenseListView.swift b/Emitron/Emitron/UI/Settings/Licenses/LicenseListView.swift index cd72f7ec..829fc718 100644 --- a/Emitron/Emitron/UI/Settings/Licenses/LicenseListView.swift +++ b/Emitron/Emitron/UI/Settings/Licenses/LicenseListView.swift @@ -66,7 +66,7 @@ struct LicenseListView: View { } struct LicenseListView_Previews: PreviewProvider { - @State static var visible: Bool = true + @State static var visible = true static var previews: some View { SwiftUI.Group { diff --git a/Emitron/Emitron/UI/Settings/SettingsSelectionView.swift b/Emitron/Emitron/UI/Settings/SettingsSelectionView.swift index e97dc97a..46a2d433 100644 --- a/Emitron/Emitron/UI/Settings/SettingsSelectionView.swift +++ b/Emitron/Emitron/UI/Settings/SettingsSelectionView.swift @@ -69,7 +69,7 @@ struct SettingsSelectionView: View { displayMode: .inline ) .padding(20) - .background(Color.backgroundColor) + .background(Color.backgroundColor.edgesIgnoringSafeArea(.all)) } } diff --git a/Emitron/Emitron/UI/Settings/SettingsView.swift b/Emitron/Emitron/UI/Settings/SettingsView.swift index 8e309b2d..12dfc051 100644 --- a/Emitron/Emitron/UI/Settings/SettingsView.swift +++ b/Emitron/Emitron/UI/Settings/SettingsView.swift @@ -35,23 +35,17 @@ enum SettingsLayout { } struct SettingsView: View { - @Environment(\.presentationMode) var presentationMode: Binding @EnvironmentObject var sessionController: SessionController @EnvironmentObject var tabViewModel: TabViewModel @ObservedObject private var settingsManager = SettingsManager.current - @State private var licensesPresented: Bool = false - var showLogoutButton: Bool + @State private var licensesPresented = false var body: some View { - NavigationView { VStack { SettingsList( settingsManager: _settingsManager, canDownload: sessionController.user?.canDownload ?? false - ) - .navigationBarTitle(String.settings) - .navigationBarItems(trailing: dismissButton) - .padding([.horizontal], 20) + ).padding([.horizontal], 20) Section(header: HStack { @@ -79,7 +73,6 @@ struct SettingsView: View { } .padding([.bottom], 25) - if showLogoutButton { VStack { if sessionController.user != nil { Text("Logged in as \(sessionController.user?.username ?? "")") @@ -87,8 +80,6 @@ struct SettingsView: View { .foregroundColor(.contentText) } MainButtonView(title: "Sign Out", type: .destructive(withArrow: true)) { - presentationMode.wrappedValue.dismiss() - // This is hacky. But without it, the sheet doesn't actually dismiss. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { sessionController.logout() tabViewModel.selectedTab = .library @@ -96,35 +87,24 @@ struct SettingsView: View { } } .padding([.bottom, .horizontal], 18) - } - } - .background(Color.backgroundColor) - } - .navigationViewStyle(StackNavigationViewStyle()) + }.navigationBarTitle(String.settings) + .background(Color.backgroundColor.edgesIgnoringSafeArea(.all)) } - - var dismissButton: some View { - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - Image.close - .foregroundColor(.iconButton) +} + +#if DEBUG +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + SwiftUI.Group { + settingsView.colorScheme(.dark) + settingsView.colorScheme(.light) } } -} -//#if DEBUG -//struct SettingsView_Previews: PreviewProvider { -// static var previews: some View { -// SwiftUI.Group { -// settingsView.colorScheme(.dark) -// settingsView.colorScheme(.light) -// } -// } -// -// static var settingsView: some View { -// SettingsView(showLogoutButton: true) -// .background(Color.backgroundColor) -// } -//} -//#endif + static var settingsView: some View { + SettingsView() + .background(Color.backgroundColor) + .environmentObject(SessionController.current) + } +} +#endif diff --git a/Emitron/Emitron/UI/Shared/CheckmarkView.swift b/Emitron/Emitron/UI/Shared/CheckmarkView.swift index 56f27622..513b6f2a 100644 --- a/Emitron/Emitron/UI/Shared/CheckmarkView.swift +++ b/Emitron/Emitron/UI/Shared/CheckmarkView.swift @@ -28,7 +28,7 @@ import SwiftUI -//TODO: Refactor layout properties here +// TODO: Refactor layout properties here struct CheckmarkView: View { var isOn: Bool diff --git a/Emitron/Emitron/UI/Shared/Content Detail/Child Listing/VideoOverlayButtonView.swift b/Emitron/Emitron/UI/Shared/Content Detail/Child Listing/VideoOverlayButtonView.swift index c06b4be4..250ba27d 100644 --- a/Emitron/Emitron/UI/Shared/Content Detail/Child Listing/VideoOverlayButtonView.swift +++ b/Emitron/Emitron/UI/Shared/Content Detail/Child Listing/VideoOverlayButtonView.swift @@ -50,12 +50,12 @@ struct VideoOverlayButtonView: View { if text != nil { Text(text!) .foregroundColor(.white) - .font(.uiButtonLabel) + .font(.uiButtonLabelMedium) .fixedSize() .padding([.trailing], 8) } } - .padding(10) + .padding(12.5) .background(GeometryReader { proxy in Color.clear.preference(key: SizeKey.self, value: proxy.size) }) diff --git a/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift b/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift index 93ef0197..092585a3 100644 --- a/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift +++ b/Emitron/Emitron/UI/Shared/Content Detail/ChildContentListingView.swift @@ -56,14 +56,18 @@ private extension ChildContentListingView { SwiftUI.Group { Section { if childContentsViewModel.contents.count > 1 { - Text("Course Episodes") - .font(.uiTitle2) - .foregroundColor(.titleText) - .padding([.top, .bottom]) + HStack { + Text("Course Episodes") + .font(.uiTitle2) + .kerning(-0.5) + .foregroundColor(.titleText) + .padding([.top, .bottom]) + Spacer() + }.padding([.leading, .trailing], 20) } } - .listRowBackground(Color.backgroundColor) - .accessibility(identifier: "childContentList") + .listRowBackground(Color.backgroundColor) + .accessibility(identifier: "childContentList") if childContentsViewModel.groups.count > 1 { ForEach(childContentsViewModel.groups, id: \.id) { group in @@ -162,8 +166,8 @@ private extension ChildContentListingView { } } -//struct ChildContentListingView_Previews: PreviewProvider { +// struct ChildContentListingView_Previews: PreviewProvider { // static var previews: some View { // ChildContentListingView() // } -//} +// } diff --git a/Emitron/Emitron/UI/Shared/Content Detail/ContentDetailView.swift b/Emitron/Emitron/UI/Shared/Content Detail/ContentDetailView.swift index fd82d806..863cb3c8 100644 --- a/Emitron/Emitron/UI/Shared/Content Detail/ContentDetailView.swift +++ b/Emitron/Emitron/UI/Shared/Content Detail/ContentDetailView.swift @@ -40,12 +40,14 @@ struct ContentDetailView { } private let content: ContentListDisplayable + @State private var checkReviewRequest = false @ObservedObject private var childContentsViewModel: ChildContentsViewModel @ObservedObject private var dynamicContentViewModel: DynamicContentViewModel @EnvironmentObject private var sessionController: SessionController @State private var currentlyDisplayedVideoPlaybackViewModel: VideoPlaybackViewModel? + private let videoCompletedNotification = NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime) } // MARK: - View @@ -65,8 +67,8 @@ extension ContentDetailView: View { private extension ContentDetailView { var contentView: some View { GeometryReader { geometry in - List { - Section { + ScrollView { + VStack { if content.professional && !canStreamPro { headerImageLockedProContent(for: geometry.size.width) } else { @@ -75,20 +77,34 @@ private extension ContentDetailView { ContentSummaryView(content: content, dynamicContentViewModel: dynamicContentViewModel) .padding([.leading, .trailing], 20) - .padding(.bottom, 37) - } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.backgroundColor) - - ChildContentListingView( - childContentsViewModel: childContentsViewModel, - currentlyDisplayedVideoPlaybackViewModel: $currentlyDisplayedVideoPlaybackViewModel - ) + .background(Color.backgroundColor) + + ChildContentListingView( + childContentsViewModel: childContentsViewModel, + currentlyDisplayedVideoPlaybackViewModel: $currentlyDisplayedVideoPlaybackViewModel + ) .background(Color.backgroundColor) + } + } + } + .navigationBarTitle(Text(""), displayMode: .inline) + .background(Color.backgroundColor) + .onReceive(videoCompletedNotification) { _ in + checkReviewRequest = true + } + .onAppear { + if checkReviewRequest { + guard let lastPrompted = NSUbiquitousKeyValueStore.default.object(forKey: LookupKey.requestReview) as? TimeInterval else { return } + let lastPromptedDate = Date(timeIntervalSince1970: lastPrompted) + let currentDate = Date() + if case .completed = dynamicContentViewModel.viewProgress { + if isPastTwoWeeks(currentDate, from: lastPromptedDate) { + NotificationCenter.default.post(name: .requestReview, object: nil) + NSUbiquitousKeyValueStore.default.set(Date().timeIntervalSince1970, forKey: LookupKey.requestReview) + } + } } } - .navigationBarTitle(Text(""), displayMode: .inline) - .background(Color.backgroundColor) } var canStreamPro: Bool { user.canStreamPro } @@ -96,14 +112,6 @@ private extension ContentDetailView { var imageRatio: CGFloat { 283 / 375 } var maxImageHeight: CGFloat { 384 } - - func openSettings() { - // open iPhone settings - if - let url = URL(string: UIApplication.openSettingsURLString), - UIApplication.shared.canOpenURL(url) - { UIApplication.shared.open(url) } - } var continueOrPlayButton: some View { Button(action: { @@ -130,6 +138,11 @@ private extension ContentDetailView { } } + private func isPastTwoWeeks(_ currentWeek: Date, from lastWeek: Date) -> Bool { + let components = Calendar.current.dateComponents([.weekOfYear], from: lastWeek, to: currentWeek) + return components.weekOfYear ?? 0 >= 2 + } + func headerImagePlayableContent(for width: CGFloat) -> some View { VStack(spacing: 0, content: { ZStack(alignment: .center) { diff --git a/Emitron/Emitron/UI/Shared/Content Detail/ContentSummaryView.swift b/Emitron/Emitron/UI/Shared/Content Detail/ContentSummaryView.swift index baaa1f21..b553fca3 100644 --- a/Emitron/Emitron/UI/Shared/Content Detail/ContentSummaryView.swift +++ b/Emitron/Emitron/UI/Shared/Content Detail/ContentSummaryView.swift @@ -37,7 +37,7 @@ struct ContentSummaryView { private let content: ContentListDisplayable @ObservedObject private var dynamicContentViewModel: DynamicContentViewModel @State private var deletionConfirmation: DownloadDeletionConfirmation? - + init( content: ContentListDisplayable, dynamicContentViewModel: DynamicContentViewModel @@ -69,15 +69,18 @@ extension ContentSummaryView: View { Text(content.name) .font(.uiTitle1) + .kerning(-0.5) .lineLimit(nil) - .padding([.top], 10) + .fixedSize(horizontal: false, vertical: true) .foregroundColor(.titleText) + Spacer() + Text(content.contentSummaryMetadataString) .font(.uiCaption) .foregroundColor(.contentText) .lineSpacing(3) - .padding([.top], 10) + .fixedSize(horizontal: false, vertical: true) HStack(spacing: 30, content: { if canDownload { @@ -99,13 +102,14 @@ extension ContentSummaryView: View { .lineSpacing(3) .padding(.top, 15) .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) - Text("By \(content.contributorString)") + Text("\(String.by) \(content.contributorString)") .font(.uiCaption) .foregroundColor(.contentText) .lineLimit(2) - .padding(.top, 10) .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) } } } @@ -119,7 +123,7 @@ private extension ContentSummaryView { var canDownload: Bool { sessionController.user?.canDownload ?? false } - + var completedTag: CompletedTag? { if case .completed = dynamicContentViewModel.viewProgress { return CompletedTag() @@ -128,8 +132,8 @@ private extension ContentSummaryView { } private var bookmarkButton: some View { - //ISSUE: Changing this from button to "onTapGesture" because the tap target between the download button and the - //bookmark button somehow wasn't... clearly defined, so they'd both get pressed when the bookmark button got pressed + // ISSUE: Changing this from button to "onTapGesture" because the tap target between the download button and the + // bookmark button somehow wasn't... clearly defined, so they'd both get pressed when the bookmark button got pressed Image.bookmark .resizable() .frame(width: Layout.buttonSize, height: Layout.buttonSize) diff --git a/Emitron/Emitron/UI/Shared/Content Detail/CourseHeaderView.swift b/Emitron/Emitron/UI/Shared/Content Detail/CourseHeaderView.swift index 8828aaa5..aac796b4 100644 --- a/Emitron/Emitron/UI/Shared/Content Detail/CourseHeaderView.swift +++ b/Emitron/Emitron/UI/Shared/Content Detail/CourseHeaderView.swift @@ -37,6 +37,7 @@ struct CourseHeaderView: View { HStack { Text(name) .font(.uiTitle3) + .kerning(-0.5) .foregroundColor(.titleText) .padding(.leading, 20) Spacer() diff --git a/Emitron/Emitron/UI/Shared/Content Detail/TextListItemView.swift b/Emitron/Emitron/UI/Shared/Content Detail/TextListItemView.swift index 298a6359..6ec9dc18 100644 --- a/Emitron/Emitron/UI/Shared/Content Detail/TextListItemView.swift +++ b/Emitron/Emitron/UI/Shared/Content Detail/TextListItemView.swift @@ -57,22 +57,29 @@ struct TextListItemView: View { VStack(alignment: .leading, spacing: 5) { Text(content.name) .font(.uiTitle5) + .kerning(-0.5) .lineSpacing(3) .foregroundColor(.titleText) + .fixedSize(horizontal: false, vertical: true) Text(content.duration.minuteSecondTimeFromSeconds) .font(.uiFootnote) .foregroundColor(.contentText) } - + Spacer() - + if canDownload { - DownloadIcon(downloadProgress: dynamicContentViewModel.downloadProgress) - .onTapGesture { - download() - } - .alert(item: $deletionConfirmation, content: \.alert) + VStack { + Spacer() + DownloadIcon(downloadProgress: dynamicContentViewModel.downloadProgress) + .onTapGesture { + download() + } + .alert(item: $deletionConfirmation, content: \.alert) + .padding(.bottom, 5) + Spacer() + } } } progressBar diff --git a/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift b/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift index 54cfcbe7..28969819 100644 --- a/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift +++ b/Emitron/Emitron/UI/Shared/Content List/ContentListView.swift @@ -92,8 +92,6 @@ private extension ContentListView { SwiftUI.Group { cardsView loadMoreView - // Hack to make sure there's some spacing at the bottom of the list - Color.backgroundColor } } @@ -107,7 +105,7 @@ private extension ContentListView { navLink(for: partialContent) .buttonStyle(PlainButtonStyle()) - //HACK: to remove navigation chevrons + // HACK: to remove navigation chevrons .padding(.trailing, -2 * .sidePadding) } } @@ -212,11 +210,13 @@ private extension ContentListView { @ViewBuilder var loadMoreView: some View { if contentRepository.totalContentNum > contentRepository.contents.count { - // HACK: To put it in the middle we have to wrap it in Geometry Reader - GeometryReader { _ in - ActivityIndicator() - .onAppear(perform: contentRepository.loadMore) - } + HStack { + Spacer() + ActivityIndicator() + Spacer() + }.padding() + .background(Color.backgroundColor.edgesIgnoringSafeArea(.all)) + .onAppear(perform: contentRepository.loadMore) } } diff --git a/Emitron/Emitron/UI/Shared/CustomToggleView.swift b/Emitron/Emitron/UI/Shared/CustomToggleView.swift deleted file mode 100644 index 94fd1269..00000000 --- a/Emitron/Emitron/UI/Shared/CustomToggleView.swift +++ /dev/null @@ -1,76 +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 - -struct CustomToggle: UIViewRepresentable { - - var isOn: Bool - var callback: (() -> Void)? - - func makeUIView(context: Context) -> UISwitch { - let uiView = UISwitch() - uiView.addTarget( - context.coordinator, - action: #selector(Coordinator.didChange(sender:)), - for: .valueChanged) - - return uiView - } - - func updateUIView(_ uiView: UISwitch, context: Context) { - // This is appGreen - uiView.onTintColor = UIColor(red: 21.0 / 255.0, green: 132.0 / 255.0, blue: 67.0 / 255.0, alpha: 1) - uiView.isOn = isOn - } - - func makeCoordinator() -> CustomToggle.Coordinator { - Coordinator(self) - } - - class Coordinator: NSObject { - var control: CustomToggle - - init(_ control: CustomToggle) { - self.control = control - } - - @objc func didChange(sender: UISwitch) { - control.isOn = sender.isOn - control.callback?() - } - } -} - -struct CustomToggleView: View { - var isOn: Bool - var callback: (() -> Void)? - var body: some View { - CustomToggle(isOn: isOn, callback: callback) - } -} diff --git a/Emitron/Emitron/UI/Shared/MainButtonView.swift b/Emitron/Emitron/UI/Shared/MainButtonView.swift index e2c8a56a..8309b435 100644 --- a/Emitron/Emitron/UI/Shared/MainButtonView.swift +++ b/Emitron/Emitron/UI/Shared/MainButtonView.swift @@ -76,7 +76,7 @@ struct MainButtonView: View { Spacer() Text(title) - .font(.uiButtonLabel) + .font(.uiButtonLabelLarge) .foregroundColor(.buttonText) .padding(15) .background(GeometryReader { proxy in diff --git a/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift b/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift index 5702ca84..aa861d1b 100644 --- a/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift +++ b/Emitron/Emitron/UI/Video/FullScreenVideoPlayerViewController.swift @@ -32,7 +32,7 @@ import SwiftUI class FullScreenVideoPlayerViewController: UIViewController { @Binding var viewModel: VideoPlaybackViewModel? - private var isFullscreen: Bool = false + private var isFullscreen = false init(viewModel: Binding) { _viewModel = viewModel diff --git a/Emitron/Emitron/Utilities/MessageBus.swift b/Emitron/Emitron/Utilities/MessageBus.swift index 75f38692..d64c6de6 100644 --- a/Emitron/Emitron/Utilities/MessageBus.swift +++ b/Emitron/Emitron/Utilities/MessageBus.swift @@ -36,7 +36,7 @@ struct Message { let level: Level let message: String - var autoDismiss: Bool = true + var autoDismiss = true } extension Message { @@ -60,7 +60,7 @@ extension Message.Level { final class MessageBus: ObservableObject { @Published private(set) var currentMessage: Message? - @Published var messageVisible: Bool = false + @Published var messageVisible = false private var currentTimer: AnyCancellable? diff --git a/Emitron/Gemfile.lock b/Emitron/Gemfile.lock index 7311e82b..6383b267 100644 --- a/Emitron/Gemfile.lock +++ b/Emitron/Gemfile.lock @@ -1,52 +1,60 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.2) + CFPropertyList (3.0.3) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) + artifactory (3.0.15) atomos (0.1.3) - aws-eventstream (1.1.0) - aws-partitions (1.380.0) - aws-sdk-core (3.109.0) + aws-eventstream (1.1.1) + aws-partitions (1.447.0) + aws-sdk-core (3.114.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.39.0) - aws-sdk-core (~> 3, >= 3.109.0) + aws-sdk-kms (1.43.0) + aws-sdk-core (~> 3, >= 3.112.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.83.0) - aws-sdk-core (~> 3, >= 3.109.0) + aws-sdk-s3 (1.93.1) + aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) - aws-sigv4 (1.2.2) + aws-sigv4 (1.2.3) aws-eventstream (~> 1, >= 1.0.2) - babosa (1.0.3) + babosa (1.0.4) claide (1.0.3) colored (1.2) colored2 (3.1.2) commander-fastlane (4.4.6) highline (~> 1.7.2) declarative (0.0.20) - declarative-option (0.1.0) - digest-crc (0.6.1) - rake (~> 13.0) + digest-crc (0.6.3) + rake (>= 12.0.0, < 14.0.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) dotenv (2.7.6) - emoji_regex (3.0.0) - excon (0.76.0) - faraday (1.0.1) + emoji_regex (3.2.2) + excon (0.80.1) + faraday (1.4.1) + faraday-excon (~> 1.1) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.1) multipart-post (>= 1.2, < 3) + ruby2_keywords (>= 0.0.4) faraday-cookie_jar (0.0.7) faraday (>= 0.8.0) http-cookie (~> 1.0.0) + faraday-excon (1.1.0) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.1.0) faraday_middleware (1.0.0) faraday (~> 1.0) - fastimage (2.2.0) - fastlane (2.162.0) + fastimage (2.2.3) + fastlane (2.181.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.3, < 3.0.0) + artifactory (~> 3.0) aws-sdk-s3 (~> 1.0) babosa (>= 1.0.3, < 2.0.0) bundler (>= 1.12.0, < 3.0.0) @@ -67,6 +75,7 @@ GEM jwt (>= 2.1.0, < 3) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (~> 2.0.0) + naturally (~> 2.2) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.3) @@ -89,20 +98,35 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.0) signet (~> 0.12) - google-cloud-core (1.5.0) + google-apis-core (0.3.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 0.14) + httpclient (>= 2.8.1, < 3.0) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.0) + rexml + signet (~> 0.14) + webrick + google-apis-iamcredentials_v1 (0.3.0) + google-apis-core (~> 0.1) + google-apis-storage_v1 (0.3.0) + google-apis-core (~> 0.1) + google-cloud-core (1.6.0) google-cloud-env (~> 1.0) google-cloud-errors (~> 1.0) - google-cloud-env (1.3.3) + google-cloud-env (1.5.0) faraday (>= 0.17.3, < 2.0) - google-cloud-errors (1.0.1) - google-cloud-storage (1.29.0) + google-cloud-errors (1.1.0) + google-cloud-storage (1.31.0) addressable (~> 2.5) digest-crc (~> 0.4) - google-api-client (~> 0.33) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.1) google-cloud-core (~> 1.2) googleauth (~> 0.9) mini_mime (~> 1.0) - googleauth (0.13.1) + googleauth (0.16.1) faraday (>= 0.17.3, < 2.0) jwt (>= 1.4, < 3.0) memoist (~> 0.16) @@ -114,28 +138,30 @@ GEM domain_name (~> 0.5) httpclient (2.8.3) jmespath (1.4.0) - json (2.3.1) - jwt (2.2.2) + json (2.5.1) + jwt (2.2.3) memoist (0.16.2) - mini_magick (4.10.1) - mini_mime (1.0.2) + mini_magick (4.11.0) + mini_mime (1.1.0) multi_json (1.15.0) multipart-post (2.0.0) nanaimo (0.3.0) - naturally (2.2.0) + naturally (2.2.1) os (1.1.1) - plist (3.5.0) + plist (3.6.0) public_suffix (4.0.6) - rake (13.0.1) - representable (3.0.4) + rake (13.0.3) + representable (3.1.1) declarative (< 0.1.0) - declarative-option (< 0.2.0) + trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) + rexml (3.2.5) rouge (2.0.7) + ruby2_keywords (0.0.4) rubyzip (2.3.0) security (0.1.3) - signet (0.14.0) + signet (0.15.0) addressable (~> 2.3) faraday (>= 0.17.3, < 2.0) jwt (>= 1.5, < 3.0) @@ -147,6 +173,7 @@ GEM terminal-notifier (2.0.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) + trailblazer-option (0.1.1) tty-cursor (0.7.1) tty-screen (0.8.1) tty-spinner (0.9.3) @@ -156,8 +183,9 @@ GEM unf_ext unf_ext (0.0.7.7) unicode-display_width (1.7.0) + webrick (1.7.0) word_wrap (1.0.0) - xcodeproj (1.18.0) + xcodeproj (1.19.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) @@ -165,7 +193,7 @@ GEM nanaimo (~> 0.3.0) xcpretty (0.3.0) rouge (~> 2.0.7) - xcpretty-travis-formatter (1.0.0) + xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) PLATFORMS diff --git a/Emitron/emitronTests/Downloads/DownloadQueueManagerTest.swift b/Emitron/emitronTests/Downloads/DownloadQueueManagerTest.swift index 0729c180..556a70e6 100644 --- a/Emitron/emitronTests/Downloads/DownloadQueueManagerTest.swift +++ b/Emitron/emitronTests/Downloads/DownloadQueueManagerTest.swift @@ -107,7 +107,7 @@ class DownloadQueueManagerTest: XCTestCase { } .record() - let completion = try wait(for: recorder.completion, timeout: 5) + let completion = try wait(for: recorder.completion, timeout: 10) XCTAssert(completion == .finished) @@ -167,8 +167,8 @@ class DownloadQueueManagerTest: XCTestCase { } // This shouldn't fire cos it doesn't affect the stream - //readyForDownload = try wait(for: recorder.next(), timeout: 5) - //XCTAssertEqual(download1, readyForDownload!!.download) + // readyForDownload = try wait(for: recorder.next(), timeout: 5) + // XCTAssertEqual(download1, readyForDownload!!.download) try database.write { db in download1.state = .enqueued diff --git a/Emitron/emitronTests/Downloads/DownloadServiceTest.swift b/Emitron/emitronTests/Downloads/DownloadServiceTest.swift index aaa80a64..e0ee7bf1 100644 --- a/Emitron/emitronTests/Downloads/DownloadServiceTest.swift +++ b/Emitron/emitronTests/Downloads/DownloadServiceTest.swift @@ -86,7 +86,7 @@ class DownloadServiceTest: XCTestCase { } .record() - let completion = try wait(for: recorder.completion, timeout: 5) + let completion = try wait(for: recorder.completion, timeout: 10) XCTAssert(completion == .finished) let download = getAllDownloads().first! @@ -206,7 +206,7 @@ class DownloadServiceTest: XCTestCase { } .record() - let completion = try wait(for: recorder.completion, timeout: 5) + let completion = try wait(for: recorder.completion, timeout: 10) XCTAssert(completion == .finished) let allContentIds = fullState.childContents.map(\.id) + [collection.0.id] @@ -330,7 +330,7 @@ class DownloadServiceTest: XCTestCase { } .record() - let completion = try wait(for: recorder2.completion, timeout: 5) + let completion = try wait(for: recorder2.completion, timeout: 10) XCTAssert(completion == .finished) // Added the correct number of models @@ -354,7 +354,7 @@ class DownloadServiceTest: XCTestCase { } .record() - let completion = try wait(for: recorder.completion, timeout: 5) + let completion = try wait(for: recorder.completion, timeout: 10) XCTAssert(completion == .finished) XCTAssertEqual(2, getAllDownloads().count) @@ -381,9 +381,9 @@ class DownloadServiceTest: XCTestCase { } .record() - _ = try wait(for: recorder1.completion, timeout: 5) - _ = try wait(for: recorder2.completion, timeout: 5) - _ = try wait(for: recorder3.completion, timeout: 5) + _ = try wait(for: recorder1.completion, timeout: 10) + _ = try wait(for: recorder2.completion, timeout: 10) + _ = try wait(for: recorder3.completion, timeout: 10) XCTAssertEqual(4, getAllDownloads().count) } @@ -600,34 +600,6 @@ class DownloadServiceTest: XCTestCase { } } - func testRequestDownloadURLRespectsTheUserPreferencesOnQuality() throws { - let downloadQueueItem = try sampleDownloadQueueItem() - let attachment = AttachmentTest.Mocks.downloads.0.first { $0.kind == .sdVideoFile }! - - SettingsManager.current.downloadQuality = .sdVideoFile - - downloadService.requestDownloadURL(downloadQueueItem) - - try database.read { db in - let download = try Download.fetchOne(db, key: downloadQueueItem.download.id)! - XCTAssertNotNil(download.remoteURL) - XCTAssertEqual(attachment.url, download.remoteURL) - } - } - - func testRequestDownloadDefaultsToHDQuality() throws { - let downloadQueueItem = try sampleDownloadQueueItem() - let attachment = AttachmentTest.Mocks.downloads.0.first { $0.kind == .hdVideoFile }! - - downloadService.requestDownloadURL(downloadQueueItem) - - try database.read { db in - let download = try Download.fetchOne(db, key: downloadQueueItem.download.id)! - XCTAssertNotNil(download.remoteURL) - XCTAssertEqual(attachment.url, download.remoteURL) - } - } - func testRequestDownloadUpdatesTheStateCorrectly() throws { let downloadQueueItem = try sampleDownloadQueueItem() @@ -660,41 +632,6 @@ class DownloadServiceTest: XCTestCase { } } - func testEnqueueUpdatesStateToCompletedIfItFindsDownload() throws { - let downloadQueueItem = try sampleDownloadQueueItem() - var download = downloadQueueItem.download - download.remoteURL = URL(string: "https://example.com/amazing.mp4") - download.fileName = "\(downloadQueueItem.content.videoIdentifier!).mp4" - download.state = .readyForDownload - try database.write { db in - try download.save(db) - } - - let fileManager = FileManager.default - let documentsDirectories = fileManager.urls(for: .documentDirectory, in: .userDomainMask) - let documentsDirectory = documentsDirectories.first - let downloadsDirectory = documentsDirectory!.appendingPathComponent("downloads", isDirectory: true) - - let sampleFile = downloadsDirectory.appendingPathComponent(download.fileName!) - - XCTAssert(!fileManager.fileExists(atPath: sampleFile.path)) - - fileManager.createFile(atPath: sampleFile.path, contents: nil) - - XCTAssert(fileManager.fileExists(atPath: sampleFile.path)) - - let newQueueItem = PersistenceStore.DownloadQueueItem(download: download, content: downloadQueueItem.content) - downloadService.enqueue(downloadQueueItem: newQueueItem) - - try database.read { db in - let refreshedDownload = try Download.fetchOne(db, key: download.id)! - XCTAssertEqual(Download.State.complete, refreshedDownload.state) - XCTAssertEqual(sampleFile, refreshedDownload.localURL) - } - - try fileManager.removeItem(at: sampleFile) - } - func testEnqueueDoesNothingForADownloadWithoutARemoteURL() throws { let downloadQueueItem = try sampleDownloadQueueItem() var download = downloadQueueItem.download diff --git a/Emitron/emitronTests/Models/AttachmentTest+Mocks.swift b/Emitron/emitronTests/Models/AttachmentTest+Mocks.swift index ac34fc59..7ff4de0a 100644 --- a/Emitron/emitronTests/Models/AttachmentTest+Mocks.swift +++ b/Emitron/emitronTests/Models/AttachmentTest+Mocks.swift @@ -32,16 +32,16 @@ import SwiftyJSON extension AttachmentTest { enum Mocks { - static var downloads: ([Attachment], DataCacheUpdate) { + static var download: (Attachment, DataCacheUpdate) { loadMockFrom(filename: "Attachment_Downloads") } static var stream: (Attachment, DataCacheUpdate) { - let (attachments, cacheUpdate) = loadMockFrom(filename: "Attachment_Stream") - return (attachments.first!, cacheUpdate) + let (attachment, cacheUpdate) = loadMockFrom(filename: "Attachment_Stream") + return (attachment, cacheUpdate) } - private static func loadMockFrom(filename: String) -> ([Attachment], DataCacheUpdate) { + private static func loadMockFrom(filename: String) -> (Attachment, DataCacheUpdate) { do { let bundle = Bundle(for: AttachmentTest.self) let fileURL = bundle.url(forResource: filename, withExtension: "json") @@ -53,7 +53,7 @@ extension AttachmentTest { try AttachmentAdapter.process(resource: resource) } let cacheUpdate = try DataCacheUpdate.loadFrom(document: document) - return (attachments, cacheUpdate) + return (attachments[0], cacheUpdate) } catch { preconditionFailure("Unable to load Attachment mock: \(error)") } diff --git a/Emitron/emitronTests/Models/Mocks/Attachment_Downloads.json b/Emitron/emitronTests/Models/Mocks/Attachment_Downloads.json index a7eb5ad6..046a5597 100644 --- a/Emitron/emitronTests/Models/Mocks/Attachment_Downloads.json +++ b/Emitron/emitronTests/Models/Mocks/Attachment_Downloads.json @@ -1,13 +1,5 @@ { "data":[ - { - "id":"30194", - "type":"attachments", - "attributes":{ - "url":"https://player.vimeo.com/external/332761683.sd.mp4?s=263eeb3fa49a8e8a5fea81c267bec4071d08bc78\u0026profile_id=164\u0026oauth2_token_id=897711146", - "kind":"sd_video_file" - } - }, { "id":"30195", "type":"attachments", diff --git a/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift b/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift index 39097b3e..108ad5d3 100644 --- a/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift +++ b/Emitron/emitronTests/Persistence/PersistenceStore+DownloadsTest.swift @@ -67,7 +67,7 @@ class PersistenceStore_DownloadsTest: XCTestCase { } .record() - _ = try wait(for: recorder.completion, timeout: 5) + _ = try wait(for: recorder.completion, timeout: 10) return screencast.0 } @@ -80,7 +80,7 @@ class PersistenceStore_DownloadsTest: XCTestCase { } .record() - _ = try wait(for: recorder.completion, timeout: 5) + _ = try wait(for: recorder.completion, timeout: 10) return collection.0 } diff --git a/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift b/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift index 63e5df33..0fa9aaef 100644 --- a/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift +++ b/Emitron/emitronTests/Services/Mock/VideosServiceMock.swift @@ -47,8 +47,8 @@ class VideosServiceMock: VideosService { getVideoStreamCount += 1 } - override func getVideoDownload(for id: Int, completion: @escaping (Result) -> Void) { + override func getVideoStreamDownload(for id: Int, completion: @escaping (Result) -> Void) { getVideoDownloadCount += 1 - completion(Result.success(AttachmentTest.Mocks.downloads.0)) + completion(Result.success(AttachmentTest.Mocks.download.0)) } } diff --git a/Emitron/emitronTests/Settings/SettingsManagerTest.swift b/Emitron/emitronTests/Settings/SettingsManagerTest.swift index e19cd16e..01090e5c 100644 --- a/Emitron/emitronTests/Settings/SettingsManagerTest.swift +++ b/Emitron/emitronTests/Settings/SettingsManagerTest.swift @@ -114,10 +114,13 @@ class SettingsManagerTest: XCTestCase { settingsManager.playbackSpeed = .double settingsManager.playbackSpeed = .standard settingsManager.playbackSpeed = .onePointFive + settingsManager.playbackSpeed = .half + settingsManager.playbackSpeed = .threeQuarters + settingsManager.playbackSpeed = .onePointTwoFive - let stream = try wait(for: recorder.next(3), timeout: 5) + let stream = try wait(for: recorder.next(6), timeout: 5) - XCTAssertEqual([.double, .standard, .onePointFive], stream) + XCTAssertEqual([.double, .standard, .onePointFive, .half, .threeQuarters, .onePointTwoFive], stream) } func testClosedCaptionOnSuccessfullyPersisted() { diff --git a/Emitron/fastlane/Fastfile b/Emitron/fastlane/Fastfile index 7ed6d7dd..799ee214 100644 --- a/Emitron/fastlane/Fastfile +++ b/Emitron/fastlane/Fastfile @@ -18,6 +18,7 @@ default_platform(:ios) platform :ios do desc 'Push a new beta build to TestFlight' lane :ci_upload_beta_testflight do + app_store_connect_api_key prepare_ci_build('Beta') changelog_from_git_commits upload_to_testflight(app_identifier: 'com.razeware.emitron.ios.beta', @@ -27,6 +28,7 @@ platform :ios do desc 'Push a new production build to TestFlight' lane :ci_upload_release_testflight do + app_store_connect_api_key prepare_ci_build('Release') changelog_from_git_commits upload_to_testflight(app_identifier: 'com.razeware.emitron.ios', @@ -36,6 +38,7 @@ platform :ios do desc 'Push a new release version to App Store Connect ready for release' lane :ci_upload_release_appstore do + app_store_connect_api_key prepare_ci_build('Release') upload_to_app_store(app_identifier: 'com.razeware.emitron.ios') slack(message: 'emitron successfully uploaded to App Store Connect!') diff --git a/README.md b/README.md index 7dadf147..02d996ce 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ If you find a __bug__, please create an issue. If you find a __security vulnerability__, please contact emitron@razeware.com as soon as possible. See [SECURITY.md](SECURITY.md) for further details. -There is more info about contributing in [CONTRIBUITNG.md](CONTRIBUTING.md). +There is more info about contributing in [CONTRIBUTING.md](CONTRIBUTING.md). ## Development