mirror of
https://github.com/metabolist/metatext.git
synced 2024-11-21 15:50:59 +00:00
Autoplay
This commit is contained in:
parent
66bd3c78b9
commit
fe6aa0f115
29 changed files with 645 additions and 147 deletions
27
Caches/PlayerCache.swift
Normal file
27
Caches/PlayerCache.swift
Normal 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
|
||||
}
|
||||
}
|
|
@ -3,18 +3,18 @@
|
|||
import UIKit
|
||||
import ViewModels
|
||||
|
||||
class TableViewDataSource: UITableViewDiffableDataSource<Int, CollectionItemIdentifier> {
|
||||
final class TableViewDataSource: UITableViewDiffableDataSource<Int, CollectionItem> {
|
||||
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<Int, CollectionItemIden
|
|||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
override func apply(_ snapshot: NSDiffableDataSourceSnapshot<Int, CollectionItem>,
|
||||
animatingDifferences: Bool = true,
|
||||
completion: (() -> Void)? = nil) {
|
||||
updateQueue.async {
|
||||
super.apply(snapshot, animatingDifferences: animatingDifferences, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
10
Extensions/AppPreferences+Extensions.swift
Normal file
10
Extensions/AppPreferences+Extensions.swift
Normal file
|
@ -0,0 +1,10 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import ViewModels
|
||||
|
||||
extension AppPreferences {
|
||||
var shouldReduceMotion: Bool {
|
||||
UIAccessibility.isReduceMotionEnabled && useSystemReduceMotionForMedia
|
||||
}
|
||||
}
|
|
@ -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:
|
|
@ -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";
|
||||
|
|
|
@ -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 = "<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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
|
@ -112,6 +117,7 @@
|
|||
D06BC5E525202AD90079541D /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; 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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -164,7 +170,9 @@
|
|||
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>"; };
|
||||
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 */
|
||||
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
D0FE1C9625368A15003EF1EB /* Caches */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */,
|
||||
);
|
||||
path = Caches;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */,
|
||||
|
|
|
@ -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<Never, Error> {
|
||||
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<String> {
|
||||
userDefaultsClient.updatedInstanceFilter ?? Self.defaultFilter
|
||||
appPreferences.updatedInstanceFilter ?? Self.defaultFilter
|
||||
}
|
||||
|
||||
private func isFiltered(url: URL) -> Bool {
|
||||
|
|
106
ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift
Normal file
106
ServiceLayer/Sources/ServiceLayer/Utilities/AppPreferences.swift
Normal 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) }
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -11,7 +11,7 @@ class TableViewController: UITableViewController {
|
|||
private let loadingTableFooterView = LoadingTableFooterView()
|
||||
private let webfingerIndicatorView = WebfingerIndicatorView()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
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<StatusAttachmentsView?, Never>()
|
||||
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<URL>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,12 +7,14 @@ import ServiceLayer
|
|||
|
||||
public struct AccountViewModel: CollectionItemViewModel {
|
||||
public let events: AnyPublisher<AnyPublisher<CollectionItemEvent, Error>, Never>
|
||||
public let identification: Identification
|
||||
|
||||
private let accountService: AccountService
|
||||
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, 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) }
|
||||
|
|
|
@ -16,7 +16,7 @@ final public class CollectionItemsViewModel: ObservableObject {
|
|||
private let eventsSubject = PassthroughSubject<CollectionItemEvent, Never>()
|
||||
private let loadingSubject = PassthroughSubject<Bool, 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 lastSelectedLoadMore: LoadMore?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
@ -44,7 +44,7 @@ final public class CollectionItemsViewModel: ObservableObject {
|
|||
extension CollectionItemsViewModel: CollectionViewModel {
|
||||
public var updates: AnyPublisher<CollectionUpdate, Never> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import ServiceLayer
|
||||
|
||||
public typealias AppPreferences = ServiceLayer.AppPreferences
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import ServiceLayer
|
||||
|
||||
public typealias CollectionItem = ServiceLayer.CollectionItem
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -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<Identity, Never>, service: IdentityService) {
|
||||
init(identity: Identity,
|
||||
publisher: AnyPublisher<Identity, Never>,
|
||||
service: IdentityService,
|
||||
environment: AppEnvironment) {
|
||||
self.identity = identity
|
||||
self.service = service
|
||||
appPreferences = AppPreferences(environment: environment)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
publisher.dropFirst().assign(to: &self.$identity)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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<AnyPublisher<CollectionItemEvent, Error>, Never>
|
||||
|
||||
private let statusService: StatusService
|
||||
private let eventsSubject = PassthroughSubject<AnyPublisher<CollectionItemEvent, Error>, 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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
91
Views/MediaPreferencesView.swift
Normal file
91
Views/MediaPreferencesView.swift
Normal 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
26
Views/PlayerView.swift
Normal 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")
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue