This commit is contained in:
Justin Mazzocchi 2020-10-15 00:44:01 -07:00
parent 66bd3c78b9
commit fe6aa0f115
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
29 changed files with 645 additions and 147 deletions

27
Caches/PlayerCache.swift Normal file
View file

@ -0,0 +1,27 @@
// Copyright © 2020 Metabolist. All rights reserved.
import AVKit
final class PlayerCache {
private let cache = NSCache<NSURL, AVQueuePlayer>()
private var allURLsCached = Set<URL>()
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
}
}

View file

@ -3,18 +3,18 @@
import UIKit import UIKit
import ViewModels import ViewModels
class TableViewDataSource: UITableViewDiffableDataSource<Int, CollectionItemIdentifier> { final class TableViewDataSource: UITableViewDiffableDataSource<Int, CollectionItem> {
private let updateQueue = private let updateQueue =
DispatchQueue(label: "com.metabolist.metatext.collection-data-source.update-queue") DispatchQueue(label: "com.metabolist.metatext.collection-data-source.update-queue")
init(tableView: UITableView, viewModelProvider: @escaping (IndexPath) -> CollectionItemViewModel) { init(tableView: UITableView, viewModelProvider: @escaping (IndexPath) -> CollectionItemViewModel) {
for kind in CollectionItemIdentifier.Kind.allCases { for cellClass in CollectionItem.cellClasses {
tableView.register(kind.cellClass, forCellReuseIdentifier: String(describing: kind.cellClass)) 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( let cell = tableView.dequeueReusableCell(
withIdentifier: String(describing: identifier.kind.cellClass), withIdentifier: String(describing: item.cellClass),
for: indexPath) for: indexPath)
switch (cell, viewModelProvider(indexPath)) { switch (cell, viewModelProvider(indexPath)) {
@ -31,4 +31,12 @@ class TableViewDataSource: UITableViewDiffableDataSource<Int, CollectionItemIden
return cell return cell
} }
} }
override func apply(_ snapshot: NSDiffableDataSourceSnapshot<Int, CollectionItem>,
animatingDifferences: Bool = true,
completion: (() -> Void)? = nil) {
updateQueue.async {
super.apply(snapshot, animatingDifferences: animatingDifferences, completion: completion)
}
}
} }

View file

@ -0,0 +1,10 @@
// Copyright © 2020 Metabolist. All rights reserved.
import UIKit
import ViewModels
extension AppPreferences {
var shouldReduceMotion: Bool {
UIAccessibility.isReduceMotionEnabled && useSystemReduceMotionForMedia
}
}

View file

@ -2,7 +2,9 @@
import ViewModels import ViewModels
extension CollectionItemIdentifier.Kind { extension CollectionItem {
static let cellClasses = [StatusListCell.self, AccountListCell.self, LoadMoreCell.self]
var cellClass: AnyClass { var cellClass: AnyClass {
switch self { switch self {
case .status: case .status:

View file

@ -33,6 +33,22 @@
"load-more" = "Load More"; "load-more" = "Load More";
"pending.pending-confirmation" = "Your account is pending confirmation"; "pending.pending-confirmation" = "Your account is pending confirmation";
"preferences" = "Preferences"; "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-reading" = "Posting and Reading";
"preferences.posting" = "Posting"; "preferences.posting" = "Posting";
"preferences.use-preferences-from-server" = "Use preferences from server"; "preferences.use-preferences-from-server" = "Use preferences from server";

View file

@ -14,11 +14,13 @@
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; }; D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */; };
D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */; }; D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */; };
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D02E1F94250B13210071AD56 /* SafariView.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 */; }; D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; };
D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; }; D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; };
D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; }; D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; };
D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BC5E525202AD90079541D /* ProfileViewController.swift */; }; D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BC5E525202AD90079541D /* ProfileViewController.swift */; };
D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.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 */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; };
D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.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 */; }; 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 */; }; D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */; };
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B125251A90F400942152 /* AccountListCell.swift */; }; D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B125251A90F400942152 /* AccountListCell.swift */; };
D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B12D251A97E400942152 /* TableViewController.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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -104,6 +108,7 @@
D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = "<group>"; }; D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TouchFallthroughTextView.swift; sourceTree = "<group>"; };
D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsView.swift; sourceTree = "<group>"; }; D01F41E224F8889700D55A2D /* StatusAttachmentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusAttachmentsView.swift; sourceTree = "<group>"; };
D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = "<group>"; }; D02E1F94250B13210071AD56 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = "<group>"; };
D03B1B29253818F3008F964B /* MediaPreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreferencesView.swift; sourceTree = "<group>"; };
D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; D0625E58250F092900502611 /* StatusListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusListCell.swift; sourceTree = "<group>"; };
D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = "<group>"; }; D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = "<group>"; };
@ -112,6 +117,7 @@
D06BC5E525202AD90079541D /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; }; D06BC5E525202AD90079541D /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; }; D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; }; D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = "<group>"; };
D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppPreferences+Extensions.swift"; sourceTree = "<group>"; };
D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = "<group>"; };
D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; }; D0B32F4F250B373600311912 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = "<group>"; }; D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileCollection+Extensions.swift"; sourceTree = "<group>"; };
@ -164,7 +170,9 @@
D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountContentConfiguration.swift; sourceTree = "<group>"; }; D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountContentConfiguration.swift; sourceTree = "<group>"; };
D0F0B125251A90F400942152 /* AccountListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListCell.swift; sourceTree = "<group>"; }; D0F0B125251A90F400942152 /* AccountListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListCell.swift; sourceTree = "<group>"; };
D0F0B12D251A97E400942152 /* TableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewController.swift; sourceTree = "<group>"; }; D0F0B12D251A97E400942152 /* TableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewController.swift; sourceTree = "<group>"; };
D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionItemKind+Extensions.swift"; sourceTree = "<group>"; }; D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionItem+Extensions.swift"; sourceTree = "<group>"; };
D0FE1C8E253686F9003EF1EB /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = "<group>"; };
D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCache.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -202,6 +210,7 @@
children = ( children = (
D0C7D45224F76169001EBDBB /* Assets.xcassets */, D0C7D45224F76169001EBDBB /* Assets.xcassets */,
D0AD03552505814D0085A466 /* Base16 */, D0AD03552505814D0085A466 /* Base16 */,
D0FE1C9625368A15003EF1EB /* Caches */,
D0D7C013250440610039AD6F /* CodableBloomFilter */, D0D7C013250440610039AD6F /* CodableBloomFilter */,
D0A1F4F5252E7D2A004435BF /* Data Sources */, D0A1F4F5252E7D2A004435BF /* Data Sources */,
D085C3BB25008DEC008A6C5E /* DB */, D085C3BB25008DEC008A6C5E /* DB */,
@ -288,6 +297,7 @@
D0F0B125251A90F400942152 /* AccountListCell.swift */, D0F0B125251A90F400942152 /* AccountListCell.swift */,
D0F0B10D251A868200942152 /* AccountView.swift */, D0F0B10D251A868200942152 /* AccountView.swift */,
D0C7D42424F76169001EBDBB /* AddIdentityView.swift */, D0C7D42424F76169001EBDBB /* AddIdentityView.swift */,
D03B1B29253818F3008F964B /* MediaPreferencesView.swift */,
D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */, D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */,
D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */,
D0BEB20424FA1107001B0F04 /* FiltersView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */,
@ -298,6 +308,7 @@
D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */, D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */,
D0E569DA2529319100FA1D72 /* LoadMoreView.swift */, D0E569DA2529319100FA1D72 /* LoadMoreView.swift */,
D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */, D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */,
D0FE1C8E253686F9003EF1EB /* PlayerView.swift */,
D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */, D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */,
D0C7D42624F76169001EBDBB /* PreferencesView.swift */, D0C7D42624F76169001EBDBB /* PreferencesView.swift */,
D0B32F4F250B373600311912 /* RegistrationView.swift */, D0B32F4F250B373600311912 /* RegistrationView.swift */,
@ -344,11 +355,12 @@
D0C7D46824F76169001EBDBB /* Extensions */ = { D0C7D46824F76169001EBDBB /* Extensions */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */, D0A3C2F625390A9700739F88 /* AppPreferences+Extensions.swift */,
D01C6FAB252024BD003D0300 /* Array+Extensions.swift */, D01C6FAB252024BD003D0300 /* Array+Extensions.swift */,
D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */, D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */,
D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */, D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */,
D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */, D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */,
D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */,
D0C7D46A24F76169001EBDBB /* String+Extensions.swift */, D0C7D46A24F76169001EBDBB /* String+Extensions.swift */,
D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */, D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */,
D0030981250C6C8500EACB32 /* URL+Extensions.swift */, D0030981250C6C8500EACB32 /* URL+Extensions.swift */,
@ -367,6 +379,14 @@
path = "Notification Service Extension"; path = "Notification Service Extension";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D0FE1C9625368A15003EF1EB /* Caches */ = {
isa = PBXGroup;
children = (
D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */,
);
path = Caches;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -536,15 +556,18 @@
files = ( files = (
D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */, D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */,
D02E1F95250B13210071AD56 /* SafariView.swift in Sources */, D02E1F95250B13210071AD56 /* SafariView.swift in Sources */,
D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */,
D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */,
D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */, D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */,
D0B32F50250B373600311912 /* RegistrationView.swift in Sources */, D0B32F50250B373600311912 /* RegistrationView.swift in Sources */,
D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.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 */, D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */,
D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */,
D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */, D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */,
D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */,
D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */,
D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */, D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */,
D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */,
D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */,
@ -557,6 +580,7 @@
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */, D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */, D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */,
D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */, D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */,
D0A3C2F725390A9700739F88 /* AppPreferences+Extensions.swift in Sources */,
D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */, D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */,
D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */, D01C6FAC252024BD003D0300 /* Array+Extensions.swift in Sources */,
D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */, D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */,

View file

@ -9,11 +9,11 @@ import MastodonAPI
public struct InstanceURLService { public struct InstanceURLService {
private let httpClient: HTTPClient private let httpClient: HTTPClient
private var userDefaultsClient: UserDefaultsClient private var appPreferences: AppPreferences
public init(environment: AppEnvironment) { public init(environment: AppEnvironment) {
httpClient = HTTPClient(session: environment.session, decoder: MastodonDecoder()) 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<Never, Error> { func updateFilter() -> AnyPublisher<Never, Error> {
httpClient.request(UpdatedFilterTarget()) httpClient.request(UpdatedFilterTarget())
.handleEvents(receiveOutput: { userDefaultsClient.updateInstanceFilter($0) }) .handleEvents(receiveOutput: { appPreferences.updateInstanceFilter($0) })
.ignoreOutput() .ignoreOutput()
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -119,7 +119,7 @@ private extension InstanceURLService {
])) ]))
var filter: BloomFilter<String> { var filter: BloomFilter<String> {
userDefaultsClient.updatedInstanceFilter ?? Self.defaultFilter appPreferences.updatedInstanceFilter ?? Self.defaultFilter
} }
private func isFiltered(url: URL) -> Bool { private func isFiltered(url: URL) -> Bool {

View file

@ -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<String>? {
guard let data = self[.updatedFilter] as Data? else {
return nil
}
return try? JSONDecoder().decode(BloomFilter<String>.self, from: data)
}
func updateInstanceFilter( _ filter: BloomFilter<String>) {
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<T>(index: Item) -> T? {
get { userDefaults.value(forKey: index.rawValue) as? T }
set { userDefaults.set(newValue, forKey: index.rawValue) }
}
}

View file

@ -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<String>? {
guard let data = self[.updatedFilter] as Data? else {
return nil
}
return try? JSONDecoder().decode(BloomFilter<String>.self, from: data)
}
func updateInstanceFilter( _ filter: BloomFilter<String>) {
userDefaults.set(try? JSONEncoder().encode(filter), forKey: Item.updatedFilter.rawValue)
}
}
private extension UserDefaultsClient {
enum Item: String {
case updatedFilter
}
subscript<T>(index: Item) -> T? {
get { userDefaults.value(forKey: index.rawValue) as? T }
set { userDefaults.set(newValue, forKey: index.rawValue) }
}
}

View file

@ -11,7 +11,7 @@ class TableViewController: UITableViewController {
private let loadingTableFooterView = LoadingTableFooterView() private let loadingTableFooterView = LoadingTableFooterView()
private let webfingerIndicatorView = WebfingerIndicatorView() private let webfingerIndicatorView = WebfingerIndicatorView()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var cellHeightCaches = [CGFloat: [CollectionItemIdentifier: CGFloat]]() private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]()
private lazy var dataSource: TableViewDataSource = { private lazy var dataSource: TableViewDataSource = {
.init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:)) .init(tableView: tableView, viewModelProvider: viewModel.viewModel(indexPath:))
@ -54,6 +54,18 @@ class TableViewController: UITableViewController {
viewModel.request(maxId: nil, minId: nil) 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) { override func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.isDragging else { return } guard scrollView.isDragging else { return }
@ -62,6 +74,8 @@ class TableViewController: UITableViewController {
for loadMoreView in visibleLoadMoreViews { for loadMoreView in visibleLoadMoreViews {
loadMoreView.directionChanged(up: up) loadMoreView.directionChanged(up: up)
} }
updateAutoplayViews()
} }
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
@ -75,7 +89,7 @@ class TableViewController: UITableViewController {
forRowAt indexPath: IndexPath) { forRowAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return } 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 heightCache[item] = cell.frame.height
cellHeightCaches[tableView.frame.width] = heightCache cellHeightCaches[tableView.frame.width] = heightCache
@ -104,6 +118,12 @@ class TableViewController: UITableViewController {
} }
} }
extension TableViewController {
static let autoplayableAttachmentsView = PassthroughSubject<StatusAttachmentsView?, Never>()
static let autoplayableAttachmentsViewNotification =
Notification.Name("com.metabolist.metatext.attachment-view-became-autoplayable")
}
extension TableViewController: UITableViewDataSourcePrefetching { extension TableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
guard guard
@ -151,6 +171,9 @@ extension TableViewController {
} }
private extension TableViewController { private extension TableViewController {
static let autoplayViews = [PlayerView](repeating: .init(), count: 4)
static var visibleVideoURLs = Set<URL>()
var visibleLoadMoreViews: [LoadMoreView] { var visibleLoadMoreViews: [LoadMoreView] {
tableView.visibleCells.compactMap { $0.contentView as? LoadMoreView } tableView.visibleCells.compactMap { $0.contentView as? LoadMoreView }
} }
@ -178,9 +201,19 @@ private extension TableViewController {
tableView.publisher(for: \.contentOffset) tableView.publisher(for: \.contentOffset)
.compactMap { [weak self] _ in self?.tableView.indexPathsForVisibleRows?.first } .compactMap { [weak self] _ in self?.tableView.indexPathsForVisibleRows?.first }
.removeDuplicates()
.sink { [weak self] in self?.viewModel.viewedAtTop(indexPath: $0) } .sink { [weak self] in self?.viewModel.viewedAtTop(indexPath: $0) }
.store(in: &cancellables) .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) { func update(_ update: CollectionUpdate) {
@ -206,6 +239,8 @@ private extension TableViewController {
self.tableView.contentOffset.y -= offsetFromNavigationBar self.tableView.contentOffset.y -= offsetFromNavigationBar
} }
} }
self.updateAutoplayViews()
} }
} }
@ -263,4 +298,21 @@ private extension TableViewController {
present(activityViewController, animated: true, completion: nil) 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)
}
}
} }

View file

@ -7,12 +7,14 @@ import ServiceLayer
public struct AccountViewModel: CollectionItemViewModel { public struct AccountViewModel: CollectionItemViewModel {
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never> public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
public let identification: Identification
private let accountService: AccountService private let accountService: AccountService
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>() private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>()
init(accountService: AccountService) { init(accountService: AccountService, identification: Identification) {
self.accountService = accountService self.accountService = accountService
self.identification = identification
events = eventsSubject.eraseToAnyPublisher() events = eventsSubject.eraseToAnyPublisher()
} }
} }
@ -20,8 +22,12 @@ public struct AccountViewModel: CollectionItemViewModel {
public extension AccountViewModel { public extension AccountViewModel {
var avatarURL: URL { accountService.account.avatar } var avatarURL: URL { accountService.account.avatar }
var avatarStaticURL: URL { accountService.account.avatarStatic }
var headerURL: URL { accountService.account.header } var headerURL: URL { accountService.account.header }
var headerStaticURL: URL { accountService.account.headerStatic }
var displayName: String { accountService.account.displayName } var displayName: String { accountService.account.displayName }
var accountName: String { "@".appending(accountService.account.acct) } var accountName: String { "@".appending(accountService.account.acct) }

View file

@ -16,7 +16,7 @@ final public class CollectionItemsViewModel: ObservableObject {
private let eventsSubject = PassthroughSubject<CollectionItemEvent, Never>() private let eventsSubject = PassthroughSubject<CollectionItemEvent, Never>()
private let loadingSubject = PassthroughSubject<Bool, Never>() private let loadingSubject = PassthroughSubject<Bool, Never>()
private let expandAllSubject: CurrentValueSubject<ExpandAllState, Never> private let expandAllSubject: CurrentValueSubject<ExpandAllState, Never>
private var maintainScrollPosition: CollectionItemIdentifier? private var maintainScrollPosition: CollectionItem?
private var topVisibleIndexPath = IndexPath(item: 0, section: 0) private var topVisibleIndexPath = IndexPath(item: 0, section: 0)
private var lastSelectedLoadMore: LoadMore? private var lastSelectedLoadMore: LoadMore?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@ -44,7 +44,7 @@ final public class CollectionItemsViewModel: ObservableObject {
extension CollectionItemsViewModel: CollectionViewModel { extension CollectionItemsViewModel: CollectionViewModel {
public var updates: AnyPublisher<CollectionUpdate, Never> { public var updates: AnyPublisher<CollectionUpdate, Never> {
items.map { [weak self] in items.map { [weak self] in
CollectionUpdate(items: $0.map { $0.map(CollectionItemIdentifier.init(item:)) }, CollectionUpdate(items: $0,
maintainScrollPosition: self?.maintainScrollPosition) maintainScrollPosition: self?.maintainScrollPosition)
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
@ -145,7 +145,8 @@ extension CollectionItemsViewModel: CollectionViewModel {
} }
let viewModel = AccountViewModel( let viewModel = AccountViewModel(
accountService: collectionService.navigationService.accountService(account: account)) accountService: collectionService.navigationService.accountService(account: account),
identification: identification)
cache(viewModel: viewModel, forItem: item) cache(viewModel: viewModel, forItem: item)
@ -187,7 +188,7 @@ private extension CollectionItemsViewModel {
} }
func process(items: [[CollectionItem]]) { func process(items: [[CollectionItem]]) {
maintainScrollPosition = identifierForScrollPositionMaintenance(newItems: items) maintainScrollPosition = itemForScrollPositionMaintenance(newItems: items)
self.items.send(items) self.items.send(items)
let itemsSet = Set(items.reduce([], +)) let itemsSet = Set(items.reduce([], +))
@ -195,7 +196,7 @@ private extension CollectionItemsViewModel {
viewModelCache = viewModelCache.filter { itemsSet.contains($0.key) } viewModelCache = viewModelCache.filter { itemsSet.contains($0.key) }
} }
func identifierForScrollPositionMaintenance(newItems: [[CollectionItem]]) -> CollectionItemIdentifier? { func itemForScrollPositionMaintenance(newItems: [[CollectionItem]]) -> CollectionItem? {
let flatNewItems = newItems.reduce([], +) let flatNewItems = newItems.reduce([], +)
if collectionService is ContextService, 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 configuration.isContextParent // Maintain scroll position of parent after initial load of context
}) { }) {
return .init(item: contextParent) return contextParent
} else if collectionService is TimelineService { } else if collectionService is TimelineService {
let flatItems = items.value.reduce([], +) let flatItems = items.value.reduce([], +)
let difference = flatNewItems.difference(from: flatItems) let difference = flatNewItems.difference(from: flatItems)
@ -222,7 +223,7 @@ private extension CollectionItemsViewModel {
return status.id == loadMore.beforeStatusId return status.id == loadMore.beforeStatusId
}) { }) {
return .init(item: statusAfterLoadMore) return statusAfterLoadMore
} }
} }
} }
@ -234,7 +235,7 @@ private extension CollectionItemsViewModel {
if newItems.count > topVisibleIndexPath.section, if newItems.count > topVisibleIndexPath.section,
let newIndex = newItems[topVisibleIndexPath.section].firstIndex(of: topVisibleItem), let newIndex = newItems[topVisibleIndexPath.section].firstIndex(of: topVisibleItem),
newIndex > topVisibleIndexPath.item { newIndex > topVisibleIndexPath.item {
return .init(item: topVisibleItem) return topVisibleItem
} }
} }
} }

View file

@ -0,0 +1,5 @@
// Copyright © 2020 Metabolist. All rights reserved.
import ServiceLayer
public typealias AppPreferences = ServiceLayer.AppPreferences

View file

@ -0,0 +1,5 @@
// Copyright © 2020 Metabolist. All rights reserved.
import ServiceLayer
public typealias CollectionItem = ServiceLayer.CollectionItem

View file

@ -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
}
}
}

View file

@ -1,6 +1,6 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
public struct CollectionUpdate: Hashable { public struct CollectionUpdate: Hashable {
public let items: [[CollectionItemIdentifier]] public let items: [[CollectionItem]]
public let maintainScrollPosition: CollectionItemIdentifier? public let maintainScrollPosition: CollectionItem?
} }

View file

@ -6,11 +6,16 @@ import ServiceLayer
public final class Identification: ObservableObject { public final class Identification: ObservableObject {
@Published private(set) public var identity: Identity @Published private(set) public var identity: Identity
@Published public var appPreferences: AppPreferences
let service: IdentityService let service: IdentityService
init(identity: Identity, publisher: AnyPublisher<Identity, Never>, service: IdentityService) { init(identity: Identity,
publisher: AnyPublisher<Identity, Never>,
service: IdentityService,
environment: AppEnvironment) {
self.identity = identity self.identity = identity
self.service = service self.service = service
appPreferences = AppPreferences(environment: environment)
DispatchQueue.main.async { DispatchQueue.main.async {
publisher.dropFirst().assign(to: &self.$identity) publisher.dropFirst().assign(to: &self.$identity)

View file

@ -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
}
}

View file

@ -23,7 +23,7 @@ final public class ProfileViewModel {
identification: identification)) identification: identification))
profileService.accountServicePublisher profileService.accountServicePublisher
.map(AccountViewModel.init(accountService:)) .map { AccountViewModel(accountService: $0, identification: identification) }
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.assign(to: &$accountViewModel) .assign(to: &$accountViewModel)

View file

@ -84,25 +84,26 @@ private extension RootViewModel {
identityPublisher identityPublisher
.filter { [weak self] in $0.id != self?.navigationViewModel?.identification.identity.id } .filter { [weak self] in $0.id != self?.navigationViewModel?.identification.identity.id }
.map { [weak self] in .map { [weak self] in
guard let self = self else { return nil }
let identification = Identification( let identification = Identification(
identity: $0, identity: $0,
publisher: identityPublisher.eraseToAnyPublisher(), publisher: identityPublisher.eraseToAnyPublisher(),
service: identityService) service: identityService,
environment: self.environment)
if let self = self { identification.service.updateLastUse()
identification.service.updateLastUse() .sink { _ in } receiveValue: { _ in }
.sink { _ in } receiveValue: { _ in } .store(in: &self.cancellables)
.store(in: &self.cancellables)
self.userNotificationService.isAuthorized() self.userNotificationService.isAuthorized()
.filter { $0 } .filter { $0 }
.zip(self.registerForRemoteNotifications()) .zip(self.registerForRemoteNotifications())
.filter { identification.identity.lastRegisteredDeviceToken != $1 } .filter { identification.identity.lastRegisteredDeviceToken != $1 }
.map { ($1, identification.identity.pushSubscriptionAlerts) } .map { ($1, identification.identity.pushSubscriptionAlerts) }
.flatMap(identification.service.createPushSubscription(deviceToken:alerts:)) .flatMap(identification.service.createPushSubscription(deviceToken:alerts:))
.sink { _ in } receiveValue: { _ in } .sink { _ in } receiveValue: { _ in }
.store(in: &self.cancellables) .store(in: &self.cancellables)
}
return NavigationViewModel(identification: identification) return NavigationViewModel(identification: identification)
} }

View file

@ -18,11 +18,11 @@ public struct StatusViewModel: CollectionItemViewModel {
public let pollOptionTitles: [String] public let pollOptionTitles: [String]
public let pollEmoji: [Emoji] public let pollEmoji: [Emoji]
public var configuration = CollectionItem.StatusConfiguration.default public var configuration = CollectionItem.StatusConfiguration.default
public let identification: Identification
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never> public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
private let statusService: StatusService private let statusService: StatusService
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>() private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, Never>()
private let identification: Identification
init(statusService: StatusService, identification: Identification) { init(statusService: StatusService, identification: Identification) {
self.statusService = statusService self.statusService = statusService
@ -77,6 +77,8 @@ public extension StatusViewModel {
var avatarURL: URL { statusService.status.displayStatus.account.avatar } 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 time: String? { statusService.status.displayStatus.createdAt.timeAgo }
var contextParentTime: String { var contextParentTime: String {

View file

@ -12,7 +12,16 @@ class AccountHeaderView: UIView {
var viewModel: ProfileViewModel? { var viewModel: ProfileViewModel? {
didSet { didSet {
if let accountViewModel = viewModel?.accountViewModel { 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 noteFont = UIFont.preferredFont(forTextStyle: .callout)
let mutableNote = NSMutableAttributedString(attributedString: accountViewModel.note) let mutableNote = NSMutableAttributedString(attributedString: accountViewModel.note)

View file

@ -34,7 +34,6 @@ extension AccountView: UIContentView {
self.accountConfiguration = accountConfiguration self.accountConfiguration = accountConfiguration
avatarImageView.kf.cancelDownloadTask()
applyAccountConfiguration() applyAccountConfiguration()
} }
} }
@ -97,7 +96,16 @@ private extension AccountView {
} }
func applyAccountConfiguration() { 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 == "" { if accountConfiguration.viewModel.displayName == "" {
displayNameLabel.isHidden = true displayNameLabel.isHidden = true

View file

@ -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

26
Views/PlayerView.swift Normal file
View file

@ -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")
}
}

View file

@ -22,6 +22,11 @@ struct PreferencesView: View {
viewModel: .init(identification: identification))) viewModel: .init(identification: identification)))
} }
} }
Section(header: Text("preferences.app")) {
NavigationLink("preferences.media",
destination: MediaPreferencesView(
viewModel: .init(identification: identification)))
}
} }
.navigationTitle("preferences") .navigationTitle("preferences")
} }

View file

@ -1,46 +1,34 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
import AVKit
import Kingfisher import Kingfisher
import UIKit import UIKit
import ViewModels import ViewModels
final class StatusAttachmentView: UIView { final class StatusAttachmentView: UIView {
let playerView = PlayerView()
let imageView = AnimatedImageView() let imageView = AnimatedImageView()
let button = UIButton() 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) { init(viewModel: AttachmentViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
super.init(frame: .zero) super.init(frame: .zero)
layoutMargins = .zero initialSetup()
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)
])
} }
@available(*, unavailable) @available(*, unavailable)
@ -48,3 +36,112 @@ final class StatusAttachmentView: UIView {
fatalError("init(coder:) has not been implemented") 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)
])
}
}

View file

@ -1,5 +1,7 @@
// Copyright © 2020 Metabolist. All rights reserved. // Copyright © 2020 Metabolist. All rights reserved.
import Combine
import Network
import UIKit import UIKit
import ViewModels import ViewModels
@ -12,6 +14,7 @@ final class StatusAttachmentsView: UIView {
private let hideButtonBackground = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) private let hideButtonBackground = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
private let hideButton = UIButton() private let hideButton = UIButton()
private var aspectRatioConstraint: NSLayoutConstraint? private var aspectRatioConstraint: NSLayoutConstraint?
private var cancellables = Set<AnyCancellable>()
var viewModel: StatusViewModel? { var viewModel: StatusViewModel? {
didSet { 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 { private extension StatusAttachmentsView {
// swiftlint:disable:next function_body_length // swiftlint:disable:next function_body_length
func initialSetup() { func initialSetup() {
@ -147,5 +171,20 @@ private extension StatusAttachmentsView {
curtainButton.trailingAnchor.constraint(equalTo: curtain.contentView.trailingAnchor), curtainButton.trailingAnchor.constraint(equalTo: curtain.contentView.trailingAnchor),
curtainButton.bottomAnchor.constraint(equalTo: curtain.contentView.bottomAnchor) 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 }
} }
} }

View file

@ -292,12 +292,22 @@ private extension StatusView {
func applyStatusConfiguration() { func applyStatusConfiguration() {
let viewModel = statusConfiguration.viewModel let viewModel = statusConfiguration.viewModel
let appPreferences = viewModel.identification.appPreferences
let isContextParent = viewModel.configuration.isContextParent let isContextParent = viewModel.configuration.isContextParent
let mutableContent = NSMutableAttributedString(attributedString: viewModel.content) let mutableContent = NSMutableAttributedString(attributedString: viewModel.content)
let mutableDisplayName = NSMutableAttributedString(string: viewModel.displayName) let mutableDisplayName = NSMutableAttributedString(string: viewModel.displayName)
let mutableSpoilerText = NSMutableAttributedString(string: viewModel.spoilerText) let mutableSpoilerText = NSMutableAttributedString(string: viewModel.spoilerText)
let contentFont = UIFont.preferredFont(forTextStyle: isContextParent ? .title3 : .callout) let contentFont = UIFont.preferredFont(forTextStyle: isContextParent ? .title3 : .callout)
let contentRange = NSRange(location: 0, length: mutableContent.length) 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 contentTextView.shouldFallthrough = !isContextParent
sideStackView.isHidden = isContextParent sideStackView.isHidden = isContextParent
@ -450,8 +460,6 @@ private extension StatusView {
favoriteButton.tintColor = favoriteColor favoriteButton.tintColor = favoriteColor
favoriteButton.setTitleColor(favoriteColor, for: .normal) favoriteButton.setTitleColor(favoriteColor, for: .normal)
avatarImageView.kf.setImage(with: viewModel.avatarURL)
} }
// swiftlint:enable function_body_length // swiftlint:enable function_body_length