From fe6aa0f1153f6f0c97e8ee1f946522e1e473bb6e Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Thu, 15 Oct 2020 00:44:01 -0700 Subject: [PATCH] Autoplay --- Caches/PlayerCache.swift | 27 ++++ Data Sources/TableViewDataSource.swift | 18 ++- Extensions/AppPreferences+Extensions.swift | 10 ++ ....swift => CollectionItem+Extensions.swift} | 4 +- Localizations/Localizable.strings | 16 ++ Metatext.xcodeproj/project.pbxproj | 34 +++- .../Services/InstanceURLService.swift | 8 +- .../Utilities/AppPreferences.swift | 106 ++++++++++++ .../Utilities/UserDefaultsClient.swift | 37 ----- View Controllers/TableViewController.swift | 58 ++++++- .../Sources/ViewModels/AccountViewModel.swift | 8 +- .../ViewModels/CollectionItemsViewModel.swift | 17 +- .../ViewModels/Entities/AppPreferences.swift | 5 + .../ViewModels/Entities/CollectionItem.swift | 5 + .../Entities/CollectionItemIdentifier.swift | 31 ---- .../Entities/CollectionUpdate.swift | 4 +- .../ViewModels/Entities/Identification.swift | 7 +- .../MediaPreferencesViewModel.swift | 13 ++ .../Sources/ViewModels/ProfileViewModel.swift | 2 +- .../Sources/ViewModels/RootViewModel.swift | 29 ++-- .../Sources/ViewModels/StatusViewModel.swift | 4 +- Views/AccountHeaderView.swift | 11 +- Views/AccountView.swift | 12 +- Views/MediaPreferencesView.swift | 91 +++++++++++ Views/PlayerView.swift | 26 +++ Views/PreferencesView.swift | 5 + Views/Status/StatusAttachmentView.swift | 153 ++++++++++++++---- Views/Status/StatusAttachmentsView.swift | 39 +++++ Views/Status/StatusView.swift | 12 +- 29 files changed, 645 insertions(+), 147 deletions(-) create mode 100644 Caches/PlayerCache.swift create mode 100644 Extensions/AppPreferences+Extensions.swift rename Extensions/{CollectionItemKind+Extensions.swift => CollectionItem+Extensions.swift} (73%) create mode 100644 ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift delete mode 100644 ServiceLayer/Sources/ServiceLayer/Utilities/UserDefaultsClient.swift create mode 100644 ViewModels/Sources/ViewModels/Entities/AppPreferences.swift create mode 100644 ViewModels/Sources/ViewModels/Entities/CollectionItem.swift delete mode 100644 ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift create mode 100644 ViewModels/Sources/ViewModels/MediaPreferencesViewModel.swift create mode 100644 Views/MediaPreferencesView.swift create mode 100644 Views/PlayerView.swift diff --git a/Caches/PlayerCache.swift b/Caches/PlayerCache.swift new file mode 100644 index 0000000..02ad50d --- /dev/null +++ b/Caches/PlayerCache.swift @@ -0,0 +1,27 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import AVKit + +final class PlayerCache { + private let cache = NSCache() + private var allURLsCached = Set() + + private init() {} +} + +extension PlayerCache { + static let shared = PlayerCache() + + func player(url: URL) -> AVQueuePlayer { + if let player = cache.object(forKey: url as NSURL) { + return player + } + + let player = AVQueuePlayer(url: url) + + cache.setObject(player, forKey: url as NSURL) + allURLsCached.insert(url) + + return player + } +} diff --git a/Data Sources/TableViewDataSource.swift b/Data Sources/TableViewDataSource.swift index 76a45f4..e9481d1 100644 --- a/Data Sources/TableViewDataSource.swift +++ b/Data Sources/TableViewDataSource.swift @@ -3,18 +3,18 @@ import UIKit import ViewModels -class TableViewDataSource: UITableViewDiffableDataSource { +final class TableViewDataSource: UITableViewDiffableDataSource { private let updateQueue = DispatchQueue(label: "com.metabolist.metatext.collection-data-source.update-queue") init(tableView: UITableView, viewModelProvider: @escaping (IndexPath) -> CollectionItemViewModel) { - for kind in CollectionItemIdentifier.Kind.allCases { - tableView.register(kind.cellClass, forCellReuseIdentifier: String(describing: kind.cellClass)) + for cellClass in CollectionItem.cellClasses { + tableView.register(cellClass, forCellReuseIdentifier: String(describing: cellClass)) } - super.init(tableView: tableView) { tableView, indexPath, identifier in + super.init(tableView: tableView) { tableView, indexPath, item in let cell = tableView.dequeueReusableCell( - withIdentifier: String(describing: identifier.kind.cellClass), + withIdentifier: String(describing: item.cellClass), for: indexPath) switch (cell, viewModelProvider(indexPath)) { @@ -31,4 +31,12 @@ class TableViewDataSource: UITableViewDiffableDataSource, + animatingDifferences: Bool = true, + completion: (() -> Void)? = nil) { + updateQueue.async { + super.apply(snapshot, animatingDifferences: animatingDifferences, completion: completion) + } + } } diff --git a/Extensions/AppPreferences+Extensions.swift b/Extensions/AppPreferences+Extensions.swift new file mode 100644 index 0000000..22419ec --- /dev/null +++ b/Extensions/AppPreferences+Extensions.swift @@ -0,0 +1,10 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +extension AppPreferences { + var shouldReduceMotion: Bool { + UIAccessibility.isReduceMotionEnabled && useSystemReduceMotionForMedia + } +} diff --git a/Extensions/CollectionItemKind+Extensions.swift b/Extensions/CollectionItem+Extensions.swift similarity index 73% rename from Extensions/CollectionItemKind+Extensions.swift rename to Extensions/CollectionItem+Extensions.swift index ddcb1f5..111612b 100644 --- a/Extensions/CollectionItemKind+Extensions.swift +++ b/Extensions/CollectionItem+Extensions.swift @@ -2,7 +2,9 @@ import ViewModels -extension CollectionItemIdentifier.Kind { +extension CollectionItem { + static let cellClasses = [StatusListCell.self, AccountListCell.self, LoadMoreCell.self] + var cellClass: AnyClass { switch self { case .status: diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index 8c693f2..263c8cd 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -33,6 +33,22 @@ "load-more" = "Load More"; "pending.pending-confirmation" = "Your account is pending confirmation"; "preferences" = "Preferences"; +"preferences.app" = "App Preferences"; +"preferences.media" = "Media"; +"preferences.media.use-system-reduce-motion" = "Use system reduce motion setting"; +"preferences.media.avatars" = "Avatars"; +"preferences.media.avatars.animate" = "Animate avatars"; +"preferences.media.avatars.animate.everywhere" = "Everywhere"; +"preferences.media.avatars.animate.profiles" = "In profiles"; +"preferences.media.avatars.animate.never" = "Never"; +"preferences.media.headers" = "Headers"; +"preferences.media.headers.animate" = "Animate headers"; +"preferences.media.autoplay" = "Autoplay"; +"preferences.media.autoplay.gifs" = "GIFs"; +"preferences.media.autoplay.videos" = "Videos"; +"preferences.media.autoplay.always" = "Always"; +"preferences.media.autoplay.wifi" = "On Wi-Fi"; +"preferences.media.autoplay.never" = "Never"; "preferences.posting-reading" = "Posting and Reading"; "preferences.posting" = "Posting"; "preferences.use-preferences-from-server" = "Use preferences from server"; diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 0eaf43c..c39a0f0 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -14,11 +14,13 @@ D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; }; D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */; }; D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.swift */; }; + D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03B1B29253818F3008F964B /* MediaPreferencesView.swift */; }; D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; }; D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; }; D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; }; D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BC5E525202AD90079541D /* ProfileViewController.swift */; }; D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; }; + D0A3C2F725390A9700739F88 /* AppPreferences+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */; }; D0B8510C25259E56004E0744 /* LoadMoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B8510B25259E56004E0744 /* LoadMoreCell.swift */; }; @@ -62,7 +64,9 @@ D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */; }; D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B125251A90F400942152 /* AccountListCell.swift */; }; D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B12D251A97E400942152 /* TableViewController.swift */; }; - D0F0B136251AA12700942152 /* CollectionItemKind+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */; }; + D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */; }; + D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FE1C8E253686F9003EF1EB /* PlayerView.swift */; }; + D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -104,6 +108,7 @@ D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = ""; }; D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsView.swift; sourceTree = ""; }; D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; + D03B1B29253818F3008F964B /* MediaPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreferencesView.swift; sourceTree = ""; }; D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; }; D0625E58250F092900502611 /* StatusListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusListCell.swift; sourceTree = ""; }; D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = ""; }; @@ -112,6 +117,7 @@ D06BC5E525202AD90079541D /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = ""; }; D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = ""; }; + D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppPreferences+Extensions.swift"; sourceTree = ""; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = ""; }; D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = ""; }; D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = ""; }; @@ -164,7 +170,9 @@ D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountContentConfiguration.swift; sourceTree = ""; }; D0F0B125251A90F400942152 /* AccountListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListCell.swift; sourceTree = ""; }; D0F0B12D251A97E400942152 /* TableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewController.swift; sourceTree = ""; }; - D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionItemKind+Extensions.swift"; sourceTree = ""; }; + D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionItem+Extensions.swift"; sourceTree = ""; }; + D0FE1C8E253686F9003EF1EB /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; + D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCache.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -202,6 +210,7 @@ children = ( D0C7D45224F76169001EBDBB /* Assets.xcassets */, D0AD03552505814D0085A466 /* Base16 */, + D0FE1C9625368A15003EF1EB /* Caches */, D0D7C013250440610039AD6F /* CodableBloomFilter */, D0A1F4F5252E7D2A004435BF /* Data Sources */, D085C3BB25008DEC008A6C5E /* DB */, @@ -288,6 +297,7 @@ D0F0B125251A90F400942152 /* AccountListCell.swift */, D0F0B10D251A868200942152 /* AccountView.swift */, D0C7D42424F76169001EBDBB /* AddIdentityView.swift */, + D03B1B29253818F3008F964B /* MediaPreferencesView.swift */, D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */, D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */, @@ -298,6 +308,7 @@ D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */, D0E569DA2529319100FA1D72 /* LoadMoreView.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, + D0FE1C8E253686F9003EF1EB /* PlayerView.swift */, D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */, D0B32F4F250B373600311912 /* RegistrationView.swift */, @@ -344,11 +355,12 @@ D0C7D46824F76169001EBDBB /* Extensions */ = { isa = PBXGroup; children = ( - D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */, + D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */, D01C6FAB252024BD003D0300 /* Array+Extensions.swift */, - D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */, + D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */, D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */, D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */, + D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */, D0C7D46A24F76169001EBDBB /* String+Extensions.swift */, D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */, D0030981250C6C8500EACB32 /* URL+Extensions.swift */, @@ -367,6 +379,14 @@ path = "Notification Service Extension"; sourceTree = ""; }; + D0FE1C9625368A15003EF1EB /* Caches */ = { + isa = PBXGroup; + children = ( + D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */, + ); + path = Caches; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -536,15 +556,18 @@ files = ( D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */, D02E1F95250B13210071AD56 /* SafariView.swift in Sources */, + D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */, D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */, D0B32F50250B373600311912 /* RegistrationView.swift in Sources */, D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */, - D0F0B136251AA12700942152 /* CollectionItemKind+Extensions.swift in Sources */, + D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */, + D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */, D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */, D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, + D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */, D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, @@ -557,6 +580,7 @@ D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */, D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */, D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */, + D0A3C2F725390A9700739F88 /* AppPreferences+Extensions.swift in Sources */, D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */, D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */, D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift b/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift index 1cd25eb..3b576d8 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift @@ -9,11 +9,11 @@ import MastodonAPI public struct InstanceURLService { private let httpClient: HTTPClient - private var userDefaultsClient: UserDefaultsClient + private var appPreferences: AppPreferences public init(environment: AppEnvironment) { httpClient = HTTPClient(session: environment.session, decoder: MastodonDecoder()) - userDefaultsClient = UserDefaultsClient(userDefaults: environment.userDefaults) + appPreferences = AppPreferences(environment: environment) } } @@ -61,7 +61,7 @@ public extension InstanceURLService { func updateFilter() -> AnyPublisher { httpClient.request(UpdatedFilterTarget()) - .handleEvents(receiveOutput: { userDefaultsClient.updateInstanceFilter($0) }) + .handleEvents(receiveOutput: { appPreferences.updateInstanceFilter($0) }) .ignoreOutput() .eraseToAnyPublisher() } @@ -119,7 +119,7 @@ private extension InstanceURLService { ])) var filter: BloomFilter { - userDefaultsClient.updatedInstanceFilter ?? Self.defaultFilter + appPreferences.updatedInstanceFilter ?? Self.defaultFilter } private func isFiltered(url: URL) -> Bool { diff --git a/ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift b/ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift new file mode 100644 index 0000000..88554fa --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift @@ -0,0 +1,106 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import CodableBloomFilter +import Foundation + +public struct AppPreferences { + private let userDefaults: UserDefaults + + public init(environment: AppEnvironment) { + self.userDefaults = environment.userDefaults + } +} + +public extension AppPreferences { + enum AnimateAvatars: String, CaseIterable, Identifiable { + case everywhere + case profiles + case never + + public var id: String { rawValue } + } + + enum Autoplay: String, CaseIterable, Identifiable { + case always + case wifi + case never + + public var id: String { rawValue } + } + + var useSystemReduceMotionForMedia: Bool { + get { self[.useSystemReduceMotionForMedia] ?? true } + set { self[.useSystemReduceMotionForMedia] = newValue } + } + + var animateAvatars: AnimateAvatars { + get { + if let rawValue = self[.animateAvatars] as String?, + let value = AnimateAvatars(rawValue: rawValue) { + return value + } + + return .profiles + } + set { self[.animateAvatars] = newValue.rawValue } + } + + var animateHeaders: Bool { + get { self[.animateHeaders] ?? true } + set { self[.animateHeaders] = newValue } + } + + var autoplayGIFs: Autoplay { + get { + if let rawValue = self[.autoplayGIFs] as String?, + let value = Autoplay(rawValue: rawValue) { + return value + } + + return .always + } + set { self[.autoplayGIFs] = newValue.rawValue } + } + + var autoplayVideos: Autoplay { + get { + if let rawValue = self[.autoplayVideos] as String?, + let value = Autoplay(rawValue: rawValue) { + return value + } + + return .wifi + } + set { self[.autoplayVideos] = newValue.rawValue } + } +} + +extension AppPreferences { + var updatedInstanceFilter: BloomFilter? { + guard let data = self[.updatedFilter] as Data? else { + return nil + } + + return try? JSONDecoder().decode(BloomFilter.self, from: data) + } + + func updateInstanceFilter( _ filter: BloomFilter) { + userDefaults.set(try? JSONEncoder().encode(filter), forKey: Item.updatedFilter.rawValue) + } +} + +private extension AppPreferences { + enum Item: String { + case updatedFilter + case useSystemReduceMotionForMedia + case animateAvatars + case animateHeaders + case autoplayGIFs + case autoplayVideos + } + + subscript(index: Item) -> T? { + get { userDefaults.value(forKey: index.rawValue) as? T } + set { userDefaults.set(newValue, forKey: index.rawValue) } + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Utilities/UserDefaultsClient.swift b/ServiceLayer/Sources/ServiceLayer/Utilities/UserDefaultsClient.swift deleted file mode 100644 index 24e1aeb..0000000 --- a/ServiceLayer/Sources/ServiceLayer/Utilities/UserDefaultsClient.swift +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import CodableBloomFilter -import Foundation - -struct UserDefaultsClient { - private let userDefaults: UserDefaults - - init(userDefaults: UserDefaults) { - self.userDefaults = userDefaults - } -} - -extension UserDefaultsClient { - var updatedInstanceFilter: BloomFilter? { - guard let data = self[.updatedFilter] as Data? else { - return nil - } - - return try? JSONDecoder().decode(BloomFilter.self, from: data) - } - - func updateInstanceFilter( _ filter: BloomFilter) { - userDefaults.set(try? JSONEncoder().encode(filter), forKey: Item.updatedFilter.rawValue) - } -} - -private extension UserDefaultsClient { - enum Item: String { - case updatedFilter - } - - subscript(index: Item) -> T? { - get { userDefaults.value(forKey: index.rawValue) as? T } - set { userDefaults.set(newValue, forKey: index.rawValue) } - } -} diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 836eb97..3902826 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -11,7 +11,7 @@ class TableViewController: UITableViewController { private let loadingTableFooterView = LoadingTableFooterView() private let webfingerIndicatorView = WebfingerIndicatorView() private var cancellables = Set() - private var cellHeightCaches = [CGFloat: [CollectionItemIdentifier: CGFloat]]() + private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]() private lazy var dataSource: TableViewDataSource = { .init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:)) @@ -54,6 +54,18 @@ class TableViewController: UITableViewController { viewModel.request(maxId: nil, minId: nil) } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + updateAutoplayViews() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + updateAutoplayViews() + } + override func scrollViewDidScroll(_ scrollView: UIScrollView) { guard scrollView.isDragging else { return } @@ -62,6 +74,8 @@ class TableViewController: UITableViewController { for loadMoreView in visibleLoadMoreViews { loadMoreView.directionChanged(up: up) } + + updateAutoplayViews() } override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { @@ -75,7 +89,7 @@ class TableViewController: UITableViewController { forRowAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } - var heightCache = cellHeightCaches[tableView.frame.width] ?? [CollectionItemIdentifier: CGFloat]() + var heightCache = cellHeightCaches[tableView.frame.width] ?? [CollectionItem: CGFloat]() heightCache[item] = cell.frame.height cellHeightCaches[tableView.frame.width] = heightCache @@ -104,6 +118,12 @@ class TableViewController: UITableViewController { } } +extension TableViewController { + static let autoplayableAttachmentsView = PassthroughSubject() + static let autoplayableAttachmentsViewNotification = + Notification.Name("com.metabolist.metatext.attachment-view-became-autoplayable") +} + extension TableViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { guard @@ -151,6 +171,9 @@ extension TableViewController { } private extension TableViewController { + static let autoplayViews = [PlayerView](repeating: .init(), count: 4) + static var visibleVideoURLs = Set() + var visibleLoadMoreViews: [LoadMoreView] { tableView.visibleCells.compactMap { $0.contentView as? LoadMoreView } } @@ -178,9 +201,19 @@ private extension TableViewController { tableView.publisher(for: \.contentOffset) .compactMap { [weak self] _ in self?.tableView.indexPathsForVisibleRows?.first } - .removeDuplicates() .sink { [weak self] in self?.viewModel.viewedAtTop(indexPath: $0) } .store(in: &cancellables) + + Self.autoplayableAttachmentsView + .removeDuplicates() + .sink { + let notification = Notification( + name: Self.autoplayableAttachmentsViewNotification, + object: $0, + userInfo: nil) + NotificationCenter.default.post(notification) + } + .store(in: &cancellables) } func update(_ update: CollectionUpdate) { @@ -206,6 +239,8 @@ private extension TableViewController { self.tableView.contentOffset.y -= offsetFromNavigationBar } } + + self.updateAutoplayViews() } } @@ -263,4 +298,21 @@ private extension TableViewController { present(activityViewController, animated: true, completion: nil) } + + func updateAutoplayViews() { + if let visibleView = navigationController?.visibleViewController?.view, + view.isDescendant(of: visibleView), + let superview = view.superview, + let attachmentsViewClosestToCenter = tableView.visibleCells + .compactMap({ ($0.contentView as? StatusView)?.attachmentsView }) + .filter(\.shouldAutoplay) + .min(by: { + abs(superview.convert($0.frame, from: $0.superview).midY - view.frame.midY) + < abs(superview.convert($1.frame, from: $1.superview).midY - view.frame.midY) + }) { + Self.autoplayableAttachmentsView.send(attachmentsViewClosestToCenter) + } else { + Self.autoplayableAttachmentsView.send(nil) + } + } } diff --git a/ViewModels/Sources/ViewModels/AccountViewModel.swift b/ViewModels/Sources/ViewModels/AccountViewModel.swift index 15e65ad..479dc83 100644 --- a/ViewModels/Sources/ViewModels/AccountViewModel.swift +++ b/ViewModels/Sources/ViewModels/AccountViewModel.swift @@ -7,12 +7,14 @@ import ServiceLayer public struct AccountViewModel: CollectionItemViewModel { public let events: AnyPublisher, Never> + public let identification: Identification private let accountService: AccountService private let eventsSubject = PassthroughSubject, Never>() - init(accountService: AccountService) { + init(accountService: AccountService, identification: Identification) { self.accountService = accountService + self.identification = identification events = eventsSubject.eraseToAnyPublisher() } } @@ -20,8 +22,12 @@ public struct AccountViewModel: CollectionItemViewModel { public extension AccountViewModel { var avatarURL: URL { accountService.account.avatar } + var avatarStaticURL: URL { accountService.account.avatarStatic } + var headerURL: URL { accountService.account.header } + var headerStaticURL: URL { accountService.account.headerStatic } + var displayName: String { accountService.account.displayName } var accountName: String { "@".appending(accountService.account.acct) } diff --git a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift index 1931913..6d82e0e 100644 --- a/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift +++ b/ViewModels/Sources/ViewModels/CollectionItemsViewModel.swift @@ -16,7 +16,7 @@ final public class CollectionItemsViewModel: ObservableObject { private let eventsSubject = PassthroughSubject() private let loadingSubject = PassthroughSubject() private let expandAllSubject: CurrentValueSubject - private var maintainScrollPosition: CollectionItemIdentifier? + private var maintainScrollPosition: CollectionItem? private var topVisibleIndexPath = IndexPath(item: 0, section: 0) private var lastSelectedLoadMore: LoadMore? private var cancellables = Set() @@ -44,7 +44,7 @@ final public class CollectionItemsViewModel: ObservableObject { extension CollectionItemsViewModel: CollectionViewModel { public var updates: AnyPublisher { items.map { [weak self] in - CollectionUpdate(items: $0.map { $0.map(CollectionItemIdentifier.init(item:)) }, + CollectionUpdate(items: $0, maintainScrollPosition: self?.maintainScrollPosition) } .eraseToAnyPublisher() @@ -145,7 +145,8 @@ extension CollectionItemsViewModel: CollectionViewModel { } let viewModel = AccountViewModel( - accountService: collectionService.navigationService.accountService(account: account)) + accountService: collectionService.navigationService.accountService(account: account), + identification: identification) cache(viewModel: viewModel, forItem: item) @@ -187,7 +188,7 @@ private extension CollectionItemsViewModel { } func process(items: [[CollectionItem]]) { - maintainScrollPosition = identifierForScrollPositionMaintenance(newItems: items) + maintainScrollPosition = itemForScrollPositionMaintenance(newItems: items) self.items.send(items) let itemsSet = Set(items.reduce([], +)) @@ -195,7 +196,7 @@ private extension CollectionItemsViewModel { viewModelCache = viewModelCache.filter { itemsSet.contains($0.key) } } - func identifierForScrollPositionMaintenance(newItems: [[CollectionItem]]) -> CollectionItemIdentifier? { + func itemForScrollPositionMaintenance(newItems: [[CollectionItem]]) -> CollectionItem? { let flatNewItems = newItems.reduce([], +) if collectionService is ContextService, @@ -205,7 +206,7 @@ private extension CollectionItemsViewModel { return configuration.isContextParent // Maintain scroll position of parent after initial load of context }) { - return .init(item: contextParent) + return contextParent } else if collectionService is TimelineService { let flatItems = items.value.reduce([], +) let difference = flatNewItems.difference(from: flatItems) @@ -222,7 +223,7 @@ private extension CollectionItemsViewModel { return status.id == loadMore.beforeStatusId }) { - return .init(item: statusAfterLoadMore) + return statusAfterLoadMore } } } @@ -234,7 +235,7 @@ private extension CollectionItemsViewModel { if newItems.count > topVisibleIndexPath.section, let newIndex = newItems[topVisibleIndexPath.section].firstIndex(of: topVisibleItem), newIndex > topVisibleIndexPath.item { - return .init(item: topVisibleItem) + return topVisibleItem } } } diff --git a/ViewModels/Sources/ViewModels/Entities/AppPreferences.swift b/ViewModels/Sources/ViewModels/Entities/AppPreferences.swift new file mode 100644 index 0000000..e09d999 --- /dev/null +++ b/ViewModels/Sources/ViewModels/Entities/AppPreferences.swift @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import ServiceLayer + +public typealias AppPreferences = ServiceLayer.AppPreferences diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItem.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItem.swift new file mode 100644 index 0000000..253f39e --- /dev/null +++ b/ViewModels/Sources/ViewModels/Entities/CollectionItem.swift @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import ServiceLayer + +public typealias CollectionItem = ServiceLayer.CollectionItem diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift deleted file mode 100644 index 719f84c..0000000 --- a/ViewModels/Sources/ViewModels/Entities/CollectionItemIdentifier.swift +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import Mastodon -import ServiceLayer - -public struct CollectionItemIdentifier: Hashable { - private let item: CollectionItem - - init(item: CollectionItem) { - self.item = item - } -} - -public extension CollectionItemIdentifier { - enum Kind: Hashable, CaseIterable { - case status - case loadMore - case account - } - - var kind: Kind { - switch item { - case .status: - return .status - case .loadMore: - return .loadMore - case .account: - return .account - } - } -} diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionUpdate.swift b/ViewModels/Sources/ViewModels/Entities/CollectionUpdate.swift index 486cb7a..41eb994 100644 --- a/ViewModels/Sources/ViewModels/Entities/CollectionUpdate.swift +++ b/ViewModels/Sources/ViewModels/Entities/CollectionUpdate.swift @@ -1,6 +1,6 @@ // Copyright © 2020 Metabolist. All rights reserved. public struct CollectionUpdate: Hashable { - public let items: [[CollectionItemIdentifier]] - public let maintainScrollPosition: CollectionItemIdentifier? + public let items: [[CollectionItem]] + public let maintainScrollPosition: CollectionItem? } diff --git a/ViewModels/Sources/ViewModels/Entities/Identification.swift b/ViewModels/Sources/ViewModels/Entities/Identification.swift index 13e333c..93eedc5 100644 --- a/ViewModels/Sources/ViewModels/Entities/Identification.swift +++ b/ViewModels/Sources/ViewModels/Entities/Identification.swift @@ -6,11 +6,16 @@ import ServiceLayer public final class Identification: ObservableObject { @Published private(set) public var identity: Identity + @Published public var appPreferences: AppPreferences let service: IdentityService - init(identity: Identity, publisher: AnyPublisher, service: IdentityService) { + init(identity: Identity, + publisher: AnyPublisher, + service: IdentityService, + environment: AppEnvironment) { self.identity = identity self.service = service + appPreferences = AppPreferences(environment: environment) DispatchQueue.main.async { publisher.dropFirst().assign(to: &self.$identity) diff --git a/ViewModels/Sources/ViewModels/MediaPreferencesViewModel.swift b/ViewModels/Sources/ViewModels/MediaPreferencesViewModel.swift new file mode 100644 index 0000000..1b53c10 --- /dev/null +++ b/ViewModels/Sources/ViewModels/MediaPreferencesViewModel.swift @@ -0,0 +1,13 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import ServiceLayer + +public final class MediaPreferencesViewModel: ObservableObject { + private let identification: Identification + + public init(identification: Identification) { + self.identification = identification + } +} diff --git a/ViewModels/Sources/ViewModels/ProfileViewModel.swift b/ViewModels/Sources/ViewModels/ProfileViewModel.swift index 7dd9600..c043eb9 100644 --- a/ViewModels/Sources/ViewModels/ProfileViewModel.swift +++ b/ViewModels/Sources/ViewModels/ProfileViewModel.swift @@ -23,7 +23,7 @@ final public class ProfileViewModel { identification: identification)) profileService.accountServicePublisher - .map(AccountViewModel.init(accountService:)) + .map { AccountViewModel(accountService: $0, identification: identification) } .assignErrorsToAlertItem(to: \.alertItem, on: self) .assign(to: &$accountViewModel) diff --git a/ViewModels/Sources/ViewModels/RootViewModel.swift b/ViewModels/Sources/ViewModels/RootViewModel.swift index 22e44ae..6c2a626 100644 --- a/ViewModels/Sources/ViewModels/RootViewModel.swift +++ b/ViewModels/Sources/ViewModels/RootViewModel.swift @@ -84,25 +84,26 @@ private extension RootViewModel { identityPublisher .filter { [weak self] in $0.id != self?.navigationViewModel?.identification.identity.id } .map { [weak self] in + guard let self = self else { return nil } + let identification = Identification( identity: $0, publisher: identityPublisher.eraseToAnyPublisher(), - service: identityService) + service: identityService, + environment: self.environment) - if let self = self { - identification.service.updateLastUse() - .sink { _ in } receiveValue: { _ in } - .store(in: &self.cancellables) + identification.service.updateLastUse() + .sink { _ in } receiveValue: { _ in } + .store(in: &self.cancellables) - self.userNotificationService.isAuthorized() - .filter { $0 } - .zip(self.registerForRemoteNotifications()) - .filter { identification.identity.lastRegisteredDeviceToken != $1 } - .map { ($1, identification.identity.pushSubscriptionAlerts) } - .flatMap(identification.service.createPushSubscription(deviceToken:alerts:)) - .sink { _ in } receiveValue: { _ in } - .store(in: &self.cancellables) - } + self.userNotificationService.isAuthorized() + .filter { $0 } + .zip(self.registerForRemoteNotifications()) + .filter { identification.identity.lastRegisteredDeviceToken != $1 } + .map { ($1, identification.identity.pushSubscriptionAlerts) } + .flatMap(identification.service.createPushSubscription(deviceToken:alerts:)) + .sink { _ in } receiveValue: { _ in } + .store(in: &self.cancellables) return NavigationViewModel(identification: identification) } diff --git a/ViewModels/Sources/ViewModels/StatusViewModel.swift b/ViewModels/Sources/ViewModels/StatusViewModel.swift index 5c64b83..25ddd3b 100644 --- a/ViewModels/Sources/ViewModels/StatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusViewModel.swift @@ -18,11 +18,11 @@ public struct StatusViewModel: CollectionItemViewModel { public let pollOptionTitles: [String] public let pollEmoji: [Emoji] public var configuration = CollectionItem.StatusConfiguration.default + public let identification: Identification public let events: AnyPublisher, Never> private let statusService: StatusService private let eventsSubject = PassthroughSubject, Never>() - private let identification: Identification init(statusService: StatusService, identification: Identification) { self.statusService = statusService @@ -77,6 +77,8 @@ public extension StatusViewModel { var avatarURL: URL { statusService.status.displayStatus.account.avatar } + var avatarStaticURL: URL { statusService.status.displayStatus.account.avatarStatic } + var time: String? { statusService.status.displayStatus.createdAt.timeAgo } var contextParentTime: String { diff --git a/Views/AccountHeaderView.swift b/Views/AccountHeaderView.swift index c501a2d..242acf2 100644 --- a/Views/AccountHeaderView.swift +++ b/Views/AccountHeaderView.swift @@ -12,7 +12,16 @@ class AccountHeaderView: UIView { var viewModel: ProfileViewModel? { didSet { if let accountViewModel = viewModel?.accountViewModel { - headerImageView.kf.setImage(with: accountViewModel.headerURL) + let appPreferences = accountViewModel.identification.appPreferences + let headerURL: URL + + if !appPreferences.shouldReduceMotion, appPreferences.animateHeaders { + headerURL = accountViewModel.headerURL + } else { + headerURL = accountViewModel.headerStaticURL + } + + headerImageView.kf.setImage(with: headerURL) let noteFont = UIFont.preferredFont(forTextStyle: .callout) let mutableNote = NSMutableAttributedString(attributedString: accountViewModel.note) diff --git a/Views/AccountView.swift b/Views/AccountView.swift index b288932..a9881a1 100644 --- a/Views/AccountView.swift +++ b/Views/AccountView.swift @@ -34,7 +34,6 @@ extension AccountView: UIContentView { self.accountConfiguration = accountConfiguration - avatarImageView.kf.cancelDownloadTask() applyAccountConfiguration() } } @@ -97,7 +96,16 @@ private extension AccountView { } func applyAccountConfiguration() { - avatarImageView.kf.setImage(with: accountConfiguration.viewModel.avatarURL) + let appPreferences = accountConfiguration.viewModel.identification.appPreferences + let avatarURL: URL + + if !appPreferences.shouldReduceMotion && appPreferences.animateAvatars == .everywhere { + avatarURL = accountConfiguration.viewModel.avatarURL + } else { + avatarURL = accountConfiguration.viewModel.avatarStaticURL + } + + avatarImageView.kf.setImage(with: avatarURL) if accountConfiguration.viewModel.displayName == "" { displayNameLabel.isHidden = true diff --git a/Views/MediaPreferencesView.swift b/Views/MediaPreferencesView.swift new file mode 100644 index 0000000..6578ced --- /dev/null +++ b/Views/MediaPreferencesView.swift @@ -0,0 +1,91 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI +import ViewModels + +struct MediaPreferencesView: View { + @StateObject var viewModel: MediaPreferencesViewModel + @EnvironmentObject var identification: Identification + @Environment(\.accessibilityReduceMotion) var accessibilityReduceMotion + + var body: some View { + Form { + if accessibilityReduceMotion { + Section { + Toggle("preferences.media.use-system-reduce-motion", + isOn: $identification.appPreferences.useSystemReduceMotionForMedia) + } + } + Section(header: Text("preferences.media.autoplay")) { + Picker("preferences.media.autoplay.gifs", + selection: reduceMotion ? .constant(.never) : $identification.appPreferences.autoplayGIFs) { + ForEach(AppPreferences.Autoplay.allCases) { option in + Text(option.localizedStringKey).tag(option) + } + } + Picker("preferences.media.autoplay.videos", + selection: reduceMotion ? .constant(.never) : $identification.appPreferences.autoplayVideos) { + ForEach(AppPreferences.Autoplay.allCases) { option in + Text(option.localizedStringKey).tag(option) + } + } + } + .disabled(reduceMotion) + Section(header: Text("preferences.media.avatars")) { + Picker("preferences.media.avatars.animate", + selection: reduceMotion ? .constant(.never) : $identification.appPreferences.animateAvatars) { + ForEach(AppPreferences.AnimateAvatars.allCases) { option in + Text(option.localizedStringKey).tag(option) + } + } + .disabled(reduceMotion) + } + Section(header: Text("preferences.media.headers")) { + Toggle("preferences.media.headers.animate", + isOn: reduceMotion ? .constant(false) : $identification.appPreferences.animateHeaders) + .disabled(reduceMotion) + } + } + .navigationTitle("preferences.media") + } +} + +private extension MediaPreferencesView { + var reduceMotion: Bool { + identification.appPreferences.shouldReduceMotion + } +} + +extension AppPreferences.AnimateAvatars { + var localizedStringKey: LocalizedStringKey { + switch self { + case .everywhere: + return "preferences.media.avatars.animate.everywhere" + case .profiles: + return "preferences.media.avatars.animate.profiles" + case .never: + return "preferences.media.avatars.animate.never" + } + } +} + +extension AppPreferences.Autoplay { + var localizedStringKey: LocalizedStringKey { + switch self { + case .always: + return "preferences.media.autoplay.always" + case .wifi: + return "preferences.media.autoplay.wifi" + case .never: + return "preferences.media.autoplay.never" + } + } +} + +#if DEBUG +struct MediaPreferencesView_Previews: PreviewProvider { + static var previews: some View { + MediaPreferencesView(viewModel: .init(identification: .preview)) + } +} +#endif diff --git a/Views/PlayerView.swift b/Views/PlayerView.swift new file mode 100644 index 0000000..75f8180 --- /dev/null +++ b/Views/PlayerView.swift @@ -0,0 +1,26 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import AVKit +import UIKit + +class PlayerView: UIView { + override class var layerClass: AnyClass { + AVPlayerLayer.self + } + + var player: AVPlayer? { + get { (layer as? AVPlayerLayer)?.player } + set { (layer as? AVPlayerLayer)?.player = newValue } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + (layer as? AVPlayerLayer)?.videoGravity = .resizeAspectFill + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Views/PreferencesView.swift b/Views/PreferencesView.swift index 7edcf42..4c44d3a 100644 --- a/Views/PreferencesView.swift +++ b/Views/PreferencesView.swift @@ -22,6 +22,11 @@ struct PreferencesView: View { viewModel: .init(identification: identification))) } } + Section(header: Text("preferences.app")) { + NavigationLink("preferences.media", + destination: MediaPreferencesView( + viewModel: .init(identification: identification))) + } } .navigationTitle("preferences") } diff --git a/Views/Status/StatusAttachmentView.swift b/Views/Status/StatusAttachmentView.swift index 48bee0a..60af3da 100644 --- a/Views/Status/StatusAttachmentView.swift +++ b/Views/Status/StatusAttachmentView.swift @@ -1,46 +1,34 @@ // Copyright © 2020 Metabolist. All rights reserved. +import AVKit import Kingfisher import UIKit import ViewModels final class StatusAttachmentView: UIView { + let playerView = PlayerView() let imageView = AnimatedImageView() let button = UIButton() - let viewModel: AttachmentViewModel + + var playing: Bool = false { + didSet { + if playing { + play() + } else { + stop() + } + } + } + + private let viewModel: AttachmentViewModel + private var playerLooper: AVPlayerLooper? init(viewModel: AttachmentViewModel) { self.viewModel = viewModel super.init(frame: .zero) - layoutMargins = .zero - addSubview(imageView) - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.contentMode = .scaleAspectFill - imageView.clipsToBounds = true - - addSubview(button) - button.translatesAutoresizingMaskIntoConstraints = false - button.setBackgroundImage(.highlightedButtonBackground, for: .highlighted) - - switch viewModel.attachment.type { - case .image: - imageView.kf.setImage(with: viewModel.attachment.previewUrl) - default: - break - } - - NSLayoutConstraint.activate([ - imageView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - imageView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), - imageView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), - button.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), - button.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - button.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), - button.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) - ]) + initialSetup() } @available(*, unavailable) @@ -48,3 +36,112 @@ final class StatusAttachmentView: UIView { fatalError("init(coder:) has not been implemented") } } + +extension StatusAttachmentView { + func play() { + let player = PlayerCache.shared.player(url: viewModel.attachment.url) + + if let cachedPlayerLooper = Self.playerLooperCache[player] { + playerLooper = cachedPlayerLooper + } else if let item = player.currentItem { + playerLooper = AVPlayerLooper(player: player, templateItem: item) + Self.playerLooperCache[player] = playerLooper + } + + player.isMuted = true + player.play() + playerView.player = player + playerView.isHidden = false + } + + func stop() { + if let item = playerView.player?.currentItem { + let imageGenerator = AVAssetImageGenerator(asset: item.asset) + imageGenerator.requestedTimeToleranceAfter = .zero + imageGenerator.requestedTimeToleranceBefore = .zero + + if let image = try? imageGenerator.copyCGImage(at: item.currentTime(), actualTime: nil) { + imageView.image = .init(cgImage: image) + } + } + + playerView.player = nil + playerView.isHidden = true + } +} + +private extension StatusAttachmentView { + static var playerLooperCache = [AVQueuePlayer: AVPlayerLooper]() + + // swiftlint:disable:next function_body_length + func initialSetup() { + addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + + let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial) + let playView = UIVisualEffectView(effect: blurEffect) + let playVibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blurEffect)) + let playImageView = UIImageView( + image: UIImage(systemName: "play.circle", + withConfiguration: UIImage.SymbolConfiguration(textStyle: .largeTitle))) + + playImageView.translatesAutoresizingMaskIntoConstraints = false + playVibrancyView.translatesAutoresizingMaskIntoConstraints = false + playVibrancyView.contentView.addSubview(playImageView) + playView.contentView.addSubview(playVibrancyView) + + addSubview(playView) + playView.translatesAutoresizingMaskIntoConstraints = false + playView.clipsToBounds = true + playView.layer.cornerRadius = .defaultCornerRadius + playView.isHidden = viewModel.attachment.type == .image + + addSubview(playerView) + playerView.translatesAutoresizingMaskIntoConstraints = false + playerView.isHidden = true + + addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + button.setBackgroundImage(.highlightedButtonBackground, for: .highlighted) + + switch viewModel.attachment.type { + case .image, .video, .gifv: + imageView.kf.setImage(with: viewModel.attachment.previewUrl) + default: + break + } + + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + playerView.leadingAnchor.constraint(equalTo: leadingAnchor), + playerView.trailingAnchor.constraint(equalTo: trailingAnchor), + playerView.topAnchor.constraint(equalTo: topAnchor), + playerView.bottomAnchor.constraint(equalTo: bottomAnchor), + playImageView.centerXAnchor.constraint(equalTo: playView.contentView.centerXAnchor), + playImageView.centerYAnchor.constraint(equalTo: playView.contentView.centerYAnchor), + playVibrancyView.leadingAnchor.constraint(equalTo: playView.leadingAnchor), + playVibrancyView.topAnchor.constraint(equalTo: playView.topAnchor), + playVibrancyView.trailingAnchor.constraint(equalTo: playView.trailingAnchor), + playVibrancyView.bottomAnchor.constraint(equalTo: playView.bottomAnchor), + playView.centerXAnchor.constraint(equalTo: centerXAnchor), + playView.centerYAnchor.constraint(equalTo: centerYAnchor), + playView.trailingAnchor.constraint( + equalTo: playImageView.trailingAnchor, constant: .compactSpacing), + playView.bottomAnchor.constraint( + equalTo: playImageView.bottomAnchor, constant: .compactSpacing), + playImageView.topAnchor.constraint( + equalTo: playView.topAnchor, constant: .compactSpacing), + playImageView.leadingAnchor.constraint( + equalTo: playView.leadingAnchor, constant: .compactSpacing), + button.leadingAnchor.constraint(equalTo: leadingAnchor), + button.trailingAnchor.constraint(equalTo: trailingAnchor), + button.topAnchor.constraint(equalTo: topAnchor), + button.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } +} diff --git a/Views/Status/StatusAttachmentsView.swift b/Views/Status/StatusAttachmentsView.swift index ab3a96d..7d5c3b3 100644 --- a/Views/Status/StatusAttachmentsView.swift +++ b/Views/Status/StatusAttachmentsView.swift @@ -1,5 +1,7 @@ // Copyright © 2020 Metabolist. All rights reserved. +import Combine +import Network import UIKit import ViewModels @@ -12,6 +14,7 @@ final class StatusAttachmentsView: UIView { private let hideButtonBackground = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) private let hideButton = UIButton() private var aspectRatioConstraint: NSLayoutConstraint? + private var cancellables = Set() var viewModel: StatusViewModel? { didSet { @@ -75,6 +78,27 @@ final class StatusAttachmentsView: UIView { } } +extension StatusAttachmentsView { + var shouldAutoplay: Bool { + guard !isHidden, let viewModel = viewModel, viewModel.shouldShowAttachments else { return false } + + let appPreferences = viewModel.identification.appPreferences + let onWifi = NWPathMonitor(requiredInterfaceType: .wifi).currentPath.status == .satisfied + let hasVideoAttachment = viewModel.attachmentViewModels.contains { $0.attachment.type == .video } + let shouldAutoplayVideo = appPreferences.autoplayVideos == .always + || appPreferences.autoplayVideos == .wifi && onWifi + + if hasVideoAttachment && shouldAutoplayVideo { + return true + } + + let hasGIFAttachment = viewModel.attachmentViewModels.contains { $0.attachment.type == .gifv } + let shouldAutoplayGIF = appPreferences.autoplayGIFs == .always || appPreferences.autoplayGIFs == .wifi && onWifi + + return hasGIFAttachment && shouldAutoplayGIF + } +} + private extension StatusAttachmentsView { // swiftlint:disable:next function_body_length func initialSetup() { @@ -147,5 +171,20 @@ private extension StatusAttachmentsView { curtainButton.trailingAnchor.constraint(equalTo: curtain.contentView.trailingAnchor), curtainButton.bottomAnchor.constraint(equalTo: curtain.contentView.bottomAnchor) ]) + + NotificationCenter.default.publisher(for: TableViewController.autoplayableAttachmentsViewNotification) + .sink { [weak self] in + guard let self = self else { return } + + for attachmentView in self.attachmentViews { + attachmentView.playing = $0.object as? Self === self + } + } + .store(in: &cancellables) + } + + var attachmentViews: [StatusAttachmentView] { + (leftStackView.arrangedSubviews + rightStackView.arrangedSubviews) + .compactMap { $0 as? StatusAttachmentView } } } diff --git a/Views/Status/StatusView.swift b/Views/Status/StatusView.swift index c0ae5c6..2893a15 100644 --- a/Views/Status/StatusView.swift +++ b/Views/Status/StatusView.swift @@ -292,12 +292,22 @@ private extension StatusView { func applyStatusConfiguration() { let viewModel = statusConfiguration.viewModel + let appPreferences = viewModel.identification.appPreferences let isContextParent = viewModel.configuration.isContextParent let mutableContent = NSMutableAttributedString(attributedString: viewModel.content) let mutableDisplayName = NSMutableAttributedString(string: viewModel.displayName) let mutableSpoilerText = NSMutableAttributedString(string: viewModel.spoilerText) let contentFont = UIFont.preferredFont(forTextStyle: isContextParent ? .title3 : .callout) let contentRange = NSRange(location: 0, length: mutableContent.length) + let avatarURL: URL + + if !appPreferences.shouldReduceMotion && appPreferences.animateAvatars == .everywhere { + avatarURL = viewModel.avatarURL + } else { + avatarURL = viewModel.avatarStaticURL + } + + avatarImageView.kf.setImage(with: avatarURL) contentTextView.shouldFallthrough = !isContextParent sideStackView.isHidden = isContextParent @@ -450,8 +460,6 @@ private extension StatusView { favoriteButton.tintColor = favoriteColor favoriteButton.setTitleColor(favoriteColor, for: .normal) - - avatarImageView.kf.setImage(with: viewModel.avatarURL) } // swiftlint:enable function_body_length