From 971beddc8d785c390499fd66a6d07b69395aa623 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 10 Dec 2022 03:01:59 +0100 Subject: [PATCH] Feed cache --- Fixtures/View+Fixtures.swift | 75 --------- Model/Accounts/Account.swift | 4 + Model/Applications/InvidiousAPI.swift | 32 ++-- Model/Applications/PeerTubeAPI.swift | 5 +- Model/Applications/PipedAPI.swift | 2 +- Model/Applications/VideosAPI.swift | 2 +- Model/Applications/VideosApp.swift | 4 + Model/Cache/CacheModel.swift | 26 ++- Model/Cache/FeedCacheModel.swift | 62 ++++++++ Model/Cache/VideosCacheModel.swift | 24 +-- Model/HistoryModel.swift | 2 +- Model/Player/PlayerQueue.swift | 4 +- Shared/Constants.swift | 7 + Shared/Home/FavoriteItemView.swift | 22 ++- Shared/Player/Video Details/CommentView.swift | 10 +- Shared/Search/SearchView.swift | 2 +- Shared/Settings/AdvancedSettings.swift | 32 +++- Shared/Settings/HistorySettings.swift | 6 +- Shared/Settings/SettingsView.swift | 2 +- Shared/Subscriptions/SubscriptionsView.swift | 79 ++++++++++ .../SubscriptionsViewModel.swift | 148 ++++++++++++++++++ Shared/Views/CacheStatusHeader.swift | 24 +++ Shared/Views/SubscriptionsView.swift | 96 ------------ Yattee.xcodeproj/project.pbxproj | 51 +++++- 24 files changed, 484 insertions(+), 237 deletions(-) create mode 100644 Model/Cache/FeedCacheModel.swift create mode 100644 Shared/Subscriptions/SubscriptionsView.swift create mode 100644 Shared/Subscriptions/SubscriptionsViewModel.swift create mode 100644 Shared/Views/CacheStatusHeader.swift delete mode 100644 Shared/Views/SubscriptionsView.swift diff --git a/Fixtures/View+Fixtures.swift b/Fixtures/View+Fixtures.swift index 1e4b4c48..97c04304 100644 --- a/Fixtures/View+Fixtures.swift +++ b/Fixtures/View+Fixtures.swift @@ -4,81 +4,6 @@ import SwiftUI struct FixtureEnvironmentObjectsModifier: ViewModifier { func body(content: Content) -> some View { content - .environmentObject(AccountsModel()) - .environmentObject(InstancesManifest()) - .environmentObject(invidious) - .environmentObject(NavigationModel()) - .environmentObject(NetworkStateModel()) - .environmentObject(PipedAPI()) - .environmentObject(player) - .environmentObject(playerControls) - .environmentObject(PlayerTimeModel()) - .environmentObject(PlaylistsModel()) - .environmentObject(RecentsModel()) - .environmentObject(SettingsModel()) - .environmentObject(subscriptions) - .environmentObject(ThumbnailsModel()) - } - - private var invidious: InvidiousAPI { - let api = InvidiousAPI() - - api.validInstance = true - - return api - } - - private var player: PlayerModel { - let player = PlayerModel() - - player.currentItem = PlayerQueueItem( - Video( - app: .invidious, - videoID: "https://a/b/c", - title: "Video Name", - author: "", - length: 0, - published: "2 days ago", - views: 43434, - description: "The 14\" and 16\" MacBook Pros are incredible. I can finally retire the travel iMac.\nThat shirt! http://shop.MKBHD.com\nMacBook Pro skins: https://dbrand.com/macbooks\n\n0:00 Intro\n1:38 Top Notch Design\n2:27 Let's Talk Ports\n7:11 RIP Touchbar\n8:20 The new displays\n10:12 Living with the notch\n12:37 Performance\n19:39 Battery\n20:30 So should you get it?\n\nThe Verge Review: https://youtu.be/ftU1HzBKd5Y\nTyler Stalman Review: https://youtu.be/I10WMJV96ns\nDeveloper's tweet: https://twitter.com/softwarejameson/status/1455971162060697613?s=09&t=WbOkVKgDdcegIdyOdurSNQ&utm_source=pocket_mylist\n\nTech I'm using right now: https://www.amazon.com/shop/MKBHD\n\nIntro Track: http://youtube.com/20syl\nPlaylist of MKBHD Intro music: https://goo.gl/B3AWV5\n\nLaptop provided by Apple for review.\n\n~\nhttp://twitter.com/MKBHD\nhttp://instagram.com/MKBHD\nhttp://facebook.com/MKBHD", - channel: .init(id: "", name: "Channel Name"), - likes: 2332, - dislikes: 30, - keywords: ["Video", "Computer", "Long Long Keyword"], - chapters: [ - .init( - title: "Abc", - image: URL(string: "https://pipedproxy.kavin.rocks/vi/rr2XfL_df3o/hqdefault_29633.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg%3D%3D&rs=AOn4CLDFDm9D5SvsIA7D3v5n5KZahLs_UA&host=i.ytimg.com")!, - start: 3 - ), - .init( - title: "Def", - image: URL(string: "https://pipedproxy.kavin.rocks/vi/rr2XfL_df3o/hqdefault_98900.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg%3D%3D&rs=AOn4CLCfjXJBJb2O2q0jT0RHIi7hARVahw&host=i.ytimg.com")!, - start: 33 - ) - ] - ) - ) - #if os(iOS) - player.playerSize = .init(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) - #endif - let local = (1 ... 10).map { Video.local(URL(string: "https://\($0)")!) } - let videos = Video.allFixtures + local - player.queue = videos.map { PlayerQueueItem($0) } - - return player - } - - private var playerControls: PlayerControlsModel { - PlayerControlsModel(presentingControls: true) - } - - private var subscriptions: SubscriptionsModel { - let subscriptions = SubscriptionsModel() - - subscriptions.channels = Video.allFixtures.map { $0.channel } - - return subscriptions } } diff --git a/Model/Accounts/Account.swift b/Model/Accounts/Account.swift index 0bcf2d41..e9471b8c 100644 --- a/Model/Accounts/Account.swift +++ b/Model/Accounts/Account.swift @@ -84,4 +84,8 @@ struct Account: Defaults.Serializable, Hashable, Identifiable { func hash(into hasher: inout Hasher) { hasher.combine(username) } + + var feedCacheKey: String { + "feed-\(id)" + } } diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index cce44dbb..d8ff1be5 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -9,7 +9,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { static let basePath = "/api/v1" @Published var account: Account! - @Published var validInstance = true static func withAnonymousAccountForInstanceURL(_ url: URL) -> InvidiousAPI { .init(account: Instance(app: .invidious, apiURLString: url.absoluteString).anonymousAccount) @@ -35,8 +34,6 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { func setAccount(_ account: Account) { self.account = account - validInstance = account.anonymous - configure() if !account.anonymous { @@ -45,31 +42,15 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } func validate() { - validateInstance() validateSID() } - func validateInstance() { - guard !validInstance else { - return - } - - home? - .load() - .onSuccess { _ in - self.validInstance = true - } - .onFailure { _ in - self.validInstance = false - } - } - func validateSID() { guard signedIn, !(account.token?.isEmpty ?? true) else { return } - feed? + notifications? .load() .onFailure { _ in self.updateToken(force: true) @@ -273,8 +254,17 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { resource(baseURL: account.url, path: "/feed/subscriptions") } - var feed: Resource? { + func feed(_ page: Int?) -> Resource? { resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed") + .withParam("page", String(page ?? 1)) + } + + var feed: Resource? { + resource(baseURL: account.url, path: basePathAppending("auth/feed")) + } + + var notifications: Resource? { + resource(baseURL: account.url, path: basePathAppending("auth/notifications")) } var subscriptions: Resource? { diff --git a/Model/Applications/PeerTubeAPI.swift b/Model/Applications/PeerTubeAPI.swift index 0ef53327..72d8ce93 100644 --- a/Model/Applications/PeerTubeAPI.swift +++ b/Model/Applications/PeerTubeAPI.swift @@ -70,7 +70,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI { return } - feed? + feed(1)? .load() .onFailure { _ in self.updateToken(force: true) @@ -262,8 +262,9 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI { resource(baseURL: account.url, path: "/feed/subscriptions") } - var feed: Resource? { + func feed(_ page: Int?) -> Resource? { resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed") + .withParam("page", String(page ?? 1)) } var subscriptions: Resource? { diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index 404d63f8..c4001eaa 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -220,7 +220,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { resource(baseURL: account.instance.apiURL, path: "subscriptions") } - var feed: Resource? { + func feed(_: Int?) -> Resource? { resource(baseURL: account.instance.apiURL, path: "feed") .withParam("authToken", account.token) } diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index 817e4ffd..e9594484 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -18,8 +18,8 @@ protocol VideosAPI { func video(_ id: Video.ID) -> Resource + func feed(_ page: Int?) -> Resource? var subscriptions: Resource? { get } - var feed: Resource? { get } var home: Resource? { get } var popular: Resource? { get } var playlists: Resource? { get } diff --git a/Model/Applications/VideosApp.swift b/Model/Applications/VideosApp.swift index d98137d4..03147193 100644 --- a/Model/Applications/VideosApp.swift +++ b/Model/Applications/VideosApp.swift @@ -30,6 +30,10 @@ enum VideosApp: String, CaseIterable { supportsAccounts } + var paginatesSubscriptions: Bool { + self == .invidious + } + var supportsTrendingCategories: Bool { self == .invidious } diff --git a/Model/Cache/CacheModel.swift b/Model/Cache/CacheModel.swift index 38329a34..6240324b 100644 --- a/Model/Cache/CacheModel.swift +++ b/Model/Cache/CacheModel.swift @@ -1,16 +1,40 @@ +import Cache import Foundation import Logging +import SwiftyJSON struct CacheModel { static var shared = CacheModel() + static let jsonToDataTransformer: (JSON) -> Data = { try! $0.rawData() } + static let jsonFromDataTransformer: (Data) -> JSON = { try! JSON(data: $0) } + static let jsonTransformer = Transformer(toData: jsonToDataTransformer, fromData: jsonFromDataTransformer) + let logger = Logger(label: "stream.yattee.cache") static let bookmarksGroup = "group.stream.yattee.app.bookmarks" let bookmarksDefaults = UserDefaults(suiteName: Self.bookmarksGroup) - func removeAll() { + func clearBookmarks() { guard let bookmarksDefaults else { return } bookmarksDefaults.dictionaryRepresentation().keys.forEach(bookmarksDefaults.removeObject(forKey:)) } + + func clear() { + FeedCacheModel.shared.clear() + VideosCacheModel.shared.clear() + } + + var totalSize: Int { + (FeedCacheModel.shared.storage.totalDiskStorageSize ?? 0) + + (VideosCacheModel.shared.storage.totalDiskStorageSize ?? 0) + } + + var totalSizeFormatted: String { + totalSizeFormatter.string(fromByteCount: Int64(totalSize)) + } + + private var totalSizeFormatter: ByteCountFormatter { + .init() + } } diff --git a/Model/Cache/FeedCacheModel.swift b/Model/Cache/FeedCacheModel.swift new file mode 100644 index 00000000..0f6954eb --- /dev/null +++ b/Model/Cache/FeedCacheModel.swift @@ -0,0 +1,62 @@ +import Cache +import Foundation +import Logging +import SwiftyJSON + +struct FeedCacheModel { + static let shared = FeedCacheModel() + let logger = Logger(label: "stream.yattee.cache.feed") + + static let diskConfig = DiskConfig(name: "feed") + static let memoryConfig = MemoryConfig() + + let storage = try! Storage( + diskConfig: Self.diskConfig, + memoryConfig: Self.memoryConfig, + transformer: CacheModel.jsonTransformer + ) + + func storeFeed(account: Account, videos: [Video]) { + let date = dateFormatter.string(from: Date()) + logger.info("caching feed \(account.feedCacheKey) -- \(date)") + let feedTimeObject: JSON = ["date": date] + let videosObject: JSON = ["videos": videos.map(\.json).map(\.object)] + try? storage.setObject(feedTimeObject, forKey: feedTimeCacheKey(account.feedCacheKey)) + try? storage.setObject(videosObject, forKey: account.feedCacheKey) + } + + func retrieveFeed(account: Account) -> [Video] { + logger.info("retrieving cache for \(account.feedCacheKey)") + + if let json = try? storage.object(forKey: account.feedCacheKey), + let videos = json.dictionaryValue["videos"] + { + return videos.arrayValue.map { Video.from($0) } + } + + return [] + } + + func getFeedTime(account: Account) -> Date? { + if let json = try? storage.object(forKey: feedTimeCacheKey(account.feedCacheKey)), + let string = json.dictionaryValue["date"]?.string, + let date = dateFormatter.date(from: string) + { + return date + } + + return nil + } + + func clear() { + try? storage.removeAll() + } + + private var dateFormatter: ISO8601DateFormatter { + .init() + } + + private func feedTimeCacheKey(_ feedCacheKey: String) -> String { + "\(feedCacheKey)-feedTime" + } +} diff --git a/Model/Cache/VideosCacheModel.swift b/Model/Cache/VideosCacheModel.swift index 27890e27..b950d324 100644 --- a/Model/Cache/VideosCacheModel.swift +++ b/Model/Cache/VideosCacheModel.swift @@ -7,31 +7,31 @@ struct VideosCacheModel { static let shared = VideosCacheModel() let logger = Logger(label: "stream.yattee.cache.videos") - static let jsonToDataTransformer: (JSON) -> Data = { try! $0.rawData() } - static let jsonFromDataTransformer: (Data) -> JSON = { try! JSON(data: $0) } - static let jsonTransformer = Transformer(toData: jsonToDataTransformer, fromData: jsonFromDataTransformer) + static let diskConfig = DiskConfig(name: "videos") + static let memoryConfig = MemoryConfig() - static let videosStorageDiskConfig = DiskConfig(name: "videos") - static let vidoesStorageMemoryConfig = MemoryConfig() - - let videosStorage = try! Storage( - diskConfig: Self.videosStorageDiskConfig, - memoryConfig: Self.vidoesStorageMemoryConfig, - transformer: Self.jsonTransformer + let storage = try! Storage( + diskConfig: Self.diskConfig, + memoryConfig: Self.memoryConfig, + transformer: CacheModel.jsonTransformer ) func storeVideo(_ video: Video) { logger.info("caching \(video.cacheKey)") - try? videosStorage.setObject(video.json, forKey: video.cacheKey) + try? storage.setObject(video.json, forKey: video.cacheKey) } func retrieveVideo(_ cacheKey: String) -> Video? { logger.info("retrieving cache for \(cacheKey)") - if let json = try? videosStorage.object(forKey: cacheKey) { + if let json = try? storage.object(forKey: cacheKey) { return Video.from(json) } return nil } + + func clear() { + try? storage.removeAll() + } } diff --git a/Model/HistoryModel.swift b/Model/HistoryModel.swift index b5de9336..fa759797 100644 --- a/Model/HistoryModel.swift +++ b/Model/HistoryModel.swift @@ -103,7 +103,7 @@ extension PlayerModel { func removeHistory() { removeAllWatches() - CacheModel.shared.removeAll() + CacheModel.shared.clearBookmarks() } func removeWatch(_ watch: Watch) { diff --git a/Model/Player/PlayerQueue.swift b/Model/Player/PlayerQueue.swift index ca53d345..bc92d59a 100644 --- a/Model/Player/PlayerQueue.swift +++ b/Model/Player/PlayerQueue.swift @@ -279,7 +279,7 @@ extension PlayerModel { } func loadQueueVideoDetails(_ item: PlayerQueueItem) { - guard !accounts.current.isNil, !item.hasDetailsLoaded else { return } + guard !accounts.current.isNil, !item.hasDetailsLoaded, let video = item.video else { return } let videoID = item.video?.videoID ?? item.videoID @@ -292,7 +292,7 @@ extension PlayerModel { return } - playerAPI(item.video).loadDetails(item, completionHandler: { [weak self] newItem in + playerAPI(video)?.loadDetails(item, completionHandler: { [weak self] newItem in guard let self else { return } self.queue.filter { $0.videoID == item.videoID }.forEach { item in diff --git a/Shared/Constants.swift b/Shared/Constants.swift index 18cf3e6d..5a3620ab 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -4,4 +4,11 @@ import SwiftUI struct Constants { static let yatteeProtocol = "yattee://" static let overlayAnimation = Animation.linear(duration: 0.2) + static var progressViewScale: Double { + #if os(macOS) + 0.4 + #else + 0.6 + #endif + } } diff --git a/Shared/Home/FavoriteItemView.swift b/Shared/Home/FavoriteItemView.swift index bc94299e..0fa04df3 100644 --- a/Shared/Home/FavoriteItemView.swift +++ b/Shared/Home/FavoriteItemView.swift @@ -53,13 +53,29 @@ struct FavoriteItemView: View { #endif .onAppear { resource?.addObserver(store) - resource?.loadIfNeeded() + if item.section == .subscriptions { + cacheFeed(resource?.loadIfNeeded()) + } else { + resource?.loadIfNeeded() + } } } } .onChange(of: accounts.current) { _ in resource?.addObserver(store) - resource?.load() + if item.section == .subscriptions { + cacheFeed(resource?.load()) + } else { + resource?.load() + } + } + } + + private func cacheFeed(_ request: Request?) { + request?.onSuccess { response in + if let videos: [Video] = response.typedContent() { + FeedCacheModel.shared.storeFeed(account: accounts.current, videos: videos) + } } } @@ -78,7 +94,7 @@ struct FavoriteItemView: View { switch item.section { case .subscriptions: if accounts.app.supportsSubscriptions { - return accounts.api.feed + return accounts.api.feed(1) } case .popular: diff --git a/Shared/Player/Video Details/CommentView.swift b/Shared/Player/Video Details/CommentView.swift index 25f07d83..b42b85d5 100644 --- a/Shared/Player/Video Details/CommentView.swift +++ b/Shared/Player/Video Details/CommentView.swift @@ -82,7 +82,7 @@ struct CommentView: View { repliesButton ProgressView() - .scaleEffect(progressViewScale, anchor: .center) + .scaleEffect(Constants.progressViewScale, anchor: .center) .opacity(repliesID == comment.id && !comments.repliesLoaded ? 1 : 0) .frame(maxHeight: 0) } @@ -200,14 +200,6 @@ struct CommentView: View { #endif } - private var progressViewScale: Double { - #if os(macOS) - 0.4 - #else - 0.6 - #endif - } - private var repliesList: some View { Group { let last = comments.replies.last diff --git a/Shared/Search/SearchView.swift b/Shared/Search/SearchView.swift index 58206646..7f896714 100644 --- a/Shared/Search/SearchView.swift +++ b/Shared/Search/SearchView.swift @@ -89,7 +89,7 @@ struct SearchView: View { filtersMenu } - SearchTextField(favoriteItem: $favoriteItem) + SearchTextField() } #endif } diff --git a/Shared/Settings/AdvancedSettings.swift b/Shared/Settings/AdvancedSettings.swift index 99a6b96c..30931c15 100644 --- a/Shared/Settings/AdvancedSettings.swift +++ b/Shared/Settings/AdvancedSettings.swift @@ -11,9 +11,9 @@ struct AdvancedSettings: View { @State private var countries = [String]() @State private var filesToShare = [MPVClient.logFile] - @State private var presentingInstanceForm = false @State private var presentingShareSheet = false - @State private var savedFormInstanceID: Instance.ID? + + private var settings = SettingsModel.shared var body: some View { VStack(alignment: .leading) { @@ -36,9 +36,6 @@ struct AdvancedSettings: View { .onChange(of: countryOfPublicInstances) { newCountry in InstancesManifest.shared.setPublicAccount(newCountry, asCurrent: AccountsModel.shared.current?.isPublic ?? true) } - .sheet(isPresented: $presentingInstanceForm) { - InstanceForm(savedInstanceID: $savedFormInstanceID) - } #if os(tvOS) .frame(maxWidth: 1000) #endif @@ -87,6 +84,11 @@ struct AdvancedSettings: View { logButton } } + + Section(header: SettingsHeader(text: "Cache")) { + clearCacheButton + cacheSize + } } @ViewBuilder var mpvFooter: some View { @@ -128,13 +130,27 @@ struct AdvancedSettings: View { } #endif - private var addInstanceButton: some View { + private var clearCacheButton: some View { Button { - presentingInstanceForm = true + settings.presentAlert( + Alert( + title: Text( + "Are you sure you want to clear cache?" + ), + primaryButton: .destructive(Text("Clear"), action: CacheModel.shared.clear), + secondaryButton: .cancel() + ) + ) } label: { - Label("Add Location...", systemImage: "plus") + Text("Clear all") + .foregroundColor(.red) } } + + var cacheSize: some View { + Text(String(format: "Total size: %@", CacheModel.shared.totalSizeFormatted)) + .foregroundColor(.secondary) + } } struct AdvancedSettings_Previews: PreviewProvider { diff --git a/Shared/Settings/HistorySettings.swift b/Shared/Settings/HistorySettings.swift index 869233d0..d017fc03 100644 --- a/Shared/Settings/HistorySettings.swift +++ b/Shared/Settings/HistorySettings.swift @@ -164,7 +164,9 @@ struct HistorySettings: View { struct HistorySettings_Previews: PreviewProvider { static var previews: some View { - HistorySettings() - .injectFixtureEnvironmentObjects() + VStack(alignment: .leading) { + HistorySettings() + } + .frame(minHeight: 500) } } diff --git a/Shared/Settings/SettingsView.swift b/Shared/Settings/SettingsView.swift index 8e0a01b0..dd39eccb 100644 --- a/Shared/Settings/SettingsView.swift +++ b/Shared/Settings/SettingsView.swift @@ -231,7 +231,7 @@ struct SettingsView: View { case .locations: return 600 case .advanced: - return 250 + return 350 case .help: return 650 } diff --git a/Shared/Subscriptions/SubscriptionsView.swift b/Shared/Subscriptions/SubscriptionsView.swift new file mode 100644 index 00000000..ddf2cca9 --- /dev/null +++ b/Shared/Subscriptions/SubscriptionsView.swift @@ -0,0 +1,79 @@ +import Siesta +import SwiftUI + +struct SubscriptionsView: View { + @ObservedObject private var model = SubscriptionsViewModel.shared + @ObservedObject private var accounts = AccountsModel.shared + + var videos: [ContentItem] { + ContentItem.array(of: model.videos) + } + + var body: some View { + BrowserPlayerControls { + SignInRequiredView(title: "Subscriptions".localized()) { + VerticalCells(items: videos) { + HStack { + Spacer() + + #if os(tvOS) + Button { + model.loadResources(force: true) + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + .labelStyle(.iconOnly) + .imageScale(.small) + .font(.caption2) + } + .padding(.horizontal, 10) + #endif + + CacheStatusHeader(refreshTime: model.formattedFeedTime, isLoading: model.isLoading) + } + .environment(\.loadMoreContentHandler) { model.loadNextPage() } + .onAppear { + model.loadResources() + } + .onChange(of: accounts.current) { _ in + model.reset() + model.loadResources(force: true) + } + #if os(iOS) + .refreshControl { refreshControl in + model.loadResources(force: true) { + refreshControl.endRefreshing() + } + } + .backport + .refreshable { + await model.loadResources(force: true) + } + #endif + } + } + + #if !os(tvOS) + .background( + Button("Refresh") { + model.loadResources(force: true) + } + .keyboardShortcut("r") + .opacity(0) + ) + #endif + #if os(iOS) + .navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode) + #endif + #if !os(macOS) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in + model.loadResources() + } + #endif + } +} + +struct SubscriptonsView_Previews: PreviewProvider { + static var previews: some View { + SubscriptionsView() + } +} diff --git a/Shared/Subscriptions/SubscriptionsViewModel.swift b/Shared/Subscriptions/SubscriptionsViewModel.swift new file mode 100644 index 00000000..5e76daa7 --- /dev/null +++ b/Shared/Subscriptions/SubscriptionsViewModel.swift @@ -0,0 +1,148 @@ +import Foundation +import Siesta + +final class SubscriptionsViewModel: ObservableObject { + static let shared = SubscriptionsViewModel() + + @Published var isLoading = false + @Published var videos = [Video]() + @Published private var page = 1 + + private var accounts = AccountsModel.shared + + var feed: Resource? { + accounts.api.feed(page) + } + + func loadResources(force: Bool = false, onCompletion: @escaping () -> Void = {}) { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self else { return } + + if !force { + self.loadCachedFeed() + } + + if self.accounts.app == .invidious { + // Invidious for some reason won't refresh feed until homepage is loaded + DispatchQueue.main.async { [weak self] in + guard let self, let home = self.accounts.api.home else { return } + self.request(home, force: force)? + .onCompletion { _ in + self.loadFeed(force: force, onCompletion: onCompletion) + } + } + } else { + self.loadFeed(force: force, onCompletion: onCompletion) + } + } + } + + func loadFeed(force: Bool = false, paginating: Bool = false, onCompletion: @escaping () -> Void = {}) { + DispatchQueue.main.async { [weak self] in + guard let self, + !self.isLoading, + let account = self.accounts.current + else { + self?.isLoading = false + onCompletion() + return + } + + if paginating { + self.page += 1 + } else { + self.page = 1 + } + + let feedBeforeLoad = self.feed + var request: Request? + if let feedBeforeLoad { + request = self.request(feedBeforeLoad, force: force) + } + if request != nil { + self.isLoading = true + } + + request? + .onCompletion { _ in + self.isLoading = false + onCompletion() + } + .onSuccess { response in + if let videos: [Video] = response.typedContent() { + if paginating { + self.videos.append(contentsOf: videos) + } else { + self.videos = videos + FeedCacheModel.shared.storeFeed(account: account, videos: self.videos) + } + } + } + .onFailure { error in + NavigationModel.shared.presentAlert(title: "Could not refresh Subscriptions", message: error.userMessage) + } + } + } + + func reset() { + videos.removeAll() + page = 1 + } + + func loadNextPage() { + guard accounts.app.paginatesSubscriptions, !isLoading else { return } + + loadFeed(force: true, paginating: true) + } + + var feedTime: Date? { + if let account = accounts.current { + return FeedCacheModel.shared.getFeedTime(account: account) + } + + return nil + } + + var formattedFeedTime: String { + if let feedTime { + let isSameDay = Calendar(identifier: .iso8601).isDate(feedTime, inSameDayAs: Date()) + let formatter = isSameDay ? dateFormatterForTimeOnly : dateFormatter + return formatter.string(from: feedTime) + } + + return "" + } + + private func loadCachedFeed() { + let cache = FeedCacheModel.shared.retrieveFeed(account: accounts.current) + if !cache.isEmpty { + DispatchQueue.main.async { [weak self] in + self?.videos = cache + } + } + } + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .medium + + return formatter + } + + private var dateFormatterForTimeOnly: DateFormatter { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + + return formatter + } + + private func request(_ resource: Resource, force: Bool = false) -> Request? { + if force { + return resource.load() + } + + return resource.loadIfNeeded() + } +} diff --git a/Shared/Views/CacheStatusHeader.swift b/Shared/Views/CacheStatusHeader.swift new file mode 100644 index 00000000..d974878c --- /dev/null +++ b/Shared/Views/CacheStatusHeader.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct CacheStatusHeader: View { + var refreshTime: String + var isLoading = false + + var body: some View { + HStack(spacing: 6) { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(Constants.progressViewScale, anchor: .center) + .opacity(isLoading ? 1 : 0) + Text(refreshTime) + } + .font(.caption) + .foregroundColor(.secondary) + } +} + +struct CacheStatusHeader_Previews: PreviewProvider { + static var previews: some View { + CacheStatusHeader(refreshTime: "15:10:20") + } +} diff --git a/Shared/Views/SubscriptionsView.swift b/Shared/Views/SubscriptionsView.swift deleted file mode 100644 index b0661479..00000000 --- a/Shared/Views/SubscriptionsView.swift +++ /dev/null @@ -1,96 +0,0 @@ -import Siesta -import SwiftUI - -struct SubscriptionsView: View { - @StateObject private var store = Store<[Video]>() - - @ObservedObject private var accounts = AccountsModel.shared - - var feed: Resource? { - accounts.api.feed - } - - var videos: [ContentItem] { - ContentItem.array(of: store.collection) - } - - var body: some View { - BrowserPlayerControls { - SignInRequiredView(title: "Subscriptions".localized()) { - VerticalCells(items: videos) - .onAppear { - loadResources() - } - .onChange(of: accounts.current) { _ in - loadResources(force: true) - } - #if os(iOS) - .refreshControl { refreshControl in - loadResources(force: true) { - refreshControl.endRefreshing() - } - } - .backport - .refreshable { - await loadResources(force: true) - } - #endif - } - } - - #if !os(tvOS) - .background( - Button("Refresh") { - loadResources(force: true) - } - .keyboardShortcut("r") - .opacity(0) - ) - #endif - #if os(iOS) - .navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode) - #endif - #if !os(macOS) - .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in - loadResources() - } - #endif - } - - private func loadResources(force: Bool = false, onCompletion: @escaping () -> Void = {}) { - feed?.addObserver(store) - - if accounts.app == .invidious { - // Invidious for some reason won't refresh feed until homepage is loaded - if let request = force ? accounts.api.home?.load() : accounts.api.home?.loadIfNeeded() { - request.onSuccess { _ in - loadFeed(force: force, onCompletion: onCompletion) - } - } else { - loadFeed(force: force, onCompletion: onCompletion) - } - } else { - loadFeed(force: force, onCompletion: onCompletion) - } - } - - private func loadFeed(force: Bool = false, onCompletion: @escaping () -> Void = {}) { - if let request = force ? feed?.load() : feed?.loadIfNeeded() { - request.onCompletion { _ in - onCompletion() - } - .onFailure { error in - NavigationModel.shared.presentAlert(title: "Could not refresh Subscriptions", message: error.userMessage) - } - } else { - onCompletion() - } - } -} - -struct SubscriptonsView_Previews: PreviewProvider { - static var previews: some View { - SubscriptionsView() - .injectFixtureEnvironmentObjects() - } -} diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 4ab2b379..fa7b4c18 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -550,6 +550,11 @@ 377F9F7B294403F20043F856 /* VideosCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377F9F7A294403F20043F856 /* VideosCacheModel.swift */; }; 377F9F7C294403F20043F856 /* VideosCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377F9F7A294403F20043F856 /* VideosCacheModel.swift */; }; 377F9F7D294403F20043F856 /* VideosCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377F9F7A294403F20043F856 /* VideosCacheModel.swift */; }; + 377F9F7F2944175F0043F856 /* FeedCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377F9F7E2944175F0043F856 /* FeedCacheModel.swift */; }; + 377F9F802944175F0043F856 /* FeedCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377F9F7E2944175F0043F856 /* FeedCacheModel.swift */; }; + 377F9F812944175F0043F856 /* FeedCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377F9F7E2944175F0043F856 /* FeedCacheModel.swift */; }; + 377F9F83294417B40043F856 /* Cache in Frameworks */ = {isa = PBXBuildFile; productRef = 377F9F82294417B40043F856 /* Cache */; }; + 377F9F85294417FA0043F856 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 377F9F84294417FA0043F856 /* SwiftyJSON */; }; 377FC7D5267A080300A6BBAF /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 377FC7D4267A080300A6BBAF /* SwiftyJSON */; }; 377FC7DB267A080300A6BBAF /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 377FC7DA267A080300A6BBAF /* Logging */; }; 377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF27D26737323007FC770 /* PopularView.swift */; }; @@ -818,6 +823,12 @@ 37E64DD126D597EB00C71877 /* SubscriptionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */; }; 37E64DD226D597EB00C71877 /* SubscriptionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */; }; 37E64DD326D597EB00C71877 /* SubscriptionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */; }; + 37E6D79C2944AE1A00550C3D /* SubscriptionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E6D79B2944AE1A00550C3D /* SubscriptionsViewModel.swift */; }; + 37E6D79D2944AE1A00550C3D /* SubscriptionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E6D79B2944AE1A00550C3D /* SubscriptionsViewModel.swift */; }; + 37E6D79E2944AE1A00550C3D /* SubscriptionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E6D79B2944AE1A00550C3D /* SubscriptionsViewModel.swift */; }; + 37E6D7A02944CD3800550C3D /* CacheStatusHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E6D79F2944CD3800550C3D /* CacheStatusHeader.swift */; }; + 37E6D7A12944CD3800550C3D /* CacheStatusHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E6D79F2944CD3800550C3D /* CacheStatusHeader.swift */; }; + 37E6D7A22944CD3800550C3D /* CacheStatusHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E6D79F2944CD3800550C3D /* CacheStatusHeader.swift */; }; 37E70923271CD43000D34DDE /* WelcomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E70922271CD43000D34DDE /* WelcomeScreen.swift */; }; 37E70924271CD43000D34DDE /* WelcomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E70922271CD43000D34DDE /* WelcomeScreen.swift */; }; 37E70925271CD43000D34DDE /* WelcomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E70922271CD43000D34DDE /* WelcomeScreen.swift */; }; @@ -1229,6 +1240,7 @@ 377ABC47286E5887009C986F /* Sequence+Unique.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Unique.swift"; sourceTree = ""; }; 377E17132928265900894889 /* ListRowSeparator+Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ListRowSeparator+Backport.swift"; sourceTree = ""; }; 377F9F7A294403F20043F856 /* VideosCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideosCacheModel.swift; sourceTree = ""; }; + 377F9F7E2944175F0043F856 /* FeedCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheModel.swift; sourceTree = ""; }; 377FF88A291A60310028EB0B /* OpenVideosModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenVideosModel.swift; sourceTree = ""; }; 377FF88E291A99580028EB0B /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 37824309291E58D6005DEC1C /* Open in Yattee.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Open in Yattee.entitlements"; sourceTree = ""; }; @@ -1348,6 +1360,8 @@ 37E084AB2753D95F00039B7D /* AccountsNavigationLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsNavigationLink.swift; sourceTree = ""; }; 37E2EEAA270656EC00170416 /* BrowserPlayerControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPlayerControls.swift; sourceTree = ""; }; 37E64DD026D597EB00C71877 /* SubscriptionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsModel.swift; sourceTree = ""; }; + 37E6D79B2944AE1A00550C3D /* SubscriptionsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionsViewModel.swift; sourceTree = ""; }; + 37E6D79F2944CD3800550C3D /* CacheStatusHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheStatusHeader.swift; sourceTree = ""; }; 37E70922271CD43000D34DDE /* WelcomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreen.swift; sourceTree = ""; }; 37E70926271CDDAE00D34DDE /* OpenSettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSettingsButton.swift; sourceTree = ""; }; 37E80F3B287B107F00561799 /* VideoDetailsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetailsOverlay.swift; sourceTree = ""; }; @@ -1397,7 +1411,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 377F9F85294417FA0043F856 /* SwiftyJSON in Frameworks */, 37DA0F20291DD6B8009B38CF /* Logging in Frameworks */, + 377F9F83294417B40043F856 /* Cache in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1744,9 +1760,9 @@ 37F7D82B289EB05F00E2B3D0 /* SettingsPickerModifier.swift */, 3784B23C2728B85300B09468 /* ShareButton.swift */, 376B2E0626F920D600B1D64D /* SignInRequiredView.swift */, - 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */, 37B17D9F268A1F25006AEE9B /* VideoContextMenuView.swift */, 37E70922271CD43000D34DDE /* WelcomeScreen.swift */, + 37E6D79F2944CD3800550C3D /* CacheStatusHeader.swift */, ); path = Views; sourceTree = ""; @@ -1977,6 +1993,7 @@ isa = PBXGroup; children = ( 37F5E8B9291BEF69006C15F5 /* CacheModel.swift */, + 377F9F7E2944175F0043F856 /* FeedCacheModel.swift */, 377F9F7A294403F20043F856 /* VideosCacheModel.swift */, ); path = Cache; @@ -2124,6 +2141,7 @@ 371AAE2626CEBF1600901972 /* Playlists */, 3782B95527557A2400990149 /* Search */, 37484C1726FC836500287258 /* Settings */, + 37E6D79A2944ADCB00550C3D /* Subscriptions */, 371AAE2526CEBF0B00901972 /* Trending */, 371AAE2726CEBF4700901972 /* Videos */, 371AAE2826CEC7D900901972 /* Views */, @@ -2294,6 +2312,15 @@ path = Vendor; sourceTree = ""; }; + 37E6D79A2944ADCB00550C3D /* Subscriptions */ = { + isa = PBXGroup; + children = ( + 37AAF29F26741C97007FC770 /* SubscriptionsView.swift */, + 37E6D79B2944AE1A00550C3D /* SubscriptionsViewModel.swift */, + ); + path = Subscriptions; + sourceTree = ""; + }; 37EBD8C227AF0D7C00F1C24B /* Backends */ = { isa = PBXGroup; children = ( @@ -2368,6 +2395,8 @@ name = "Open in Yattee"; packageProductDependencies = ( 37DA0F1F291DD6B8009B38CF /* Logging */, + 377F9F82294417B40043F856 /* Cache */, + 377F9F84294417FA0043F856 /* SwiftyJSON */, ); productName = "Open in Yattee"; productReference = 37095E7F291DC85400301883 /* Open in Yattee.appex */; @@ -2872,6 +2901,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 37E6D79C2944AE1A00550C3D /* SubscriptionsViewModel.swift in Sources */, 374710052755291C00CE0F87 /* SearchTextField.swift in Sources */, 37494EA529200B14000DF176 /* DocumentsView.swift in Sources */, 374DE88028BB896C0062BBF2 /* PlayerDragGesture.swift in Sources */, @@ -2920,6 +2950,7 @@ 37D2E0D028B67DBC00F64D52 /* AnimationCompletionObserverModifier.swift in Sources */, 3727B74A27872A920021C15E /* VisualEffectBlur-iOS.swift in Sources */, 3709528829283A21001ECA40 /* RecentDocumentsView.swift in Sources */, + 377F9F7F2944175F0043F856 /* FeedCacheModel.swift in Sources */, 37977583268922F600DD52A8 /* InvidiousAPI.swift in Sources */, 374AB3D728BCAF0000DF56FB /* SeekModel.swift in Sources */, 37130A5F277657300033018A /* PersistenceController.swift in Sources */, @@ -3015,6 +3046,7 @@ 37169AA22729D98A0011DE61 /* InstancesBridge.swift in Sources */, 37C3A24527235DA70087A57A /* ChannelPlaylist.swift in Sources */, 37030FFF27B04DCC00ECDDAA /* PlayerControls.swift in Sources */, + 37E6D7A02944CD3800550C3D /* CacheStatusHeader.swift in Sources */, 374C053F272472C0009BDDBE /* PlayerSponsorBlock.swift in Sources */, 375F7410289DC35A00747050 /* PlayerBackendView.swift in Sources */, 37FB28412721B22200A57617 /* ContentItem.swift in Sources */, @@ -3268,6 +3300,7 @@ 37B81AFD26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */, 37C0697F2725C8D400F7F6CB /* CMTime+DefaultTimescale.swift in Sources */, 37A9965B26D6F8CA006E3224 /* HorizontalCells.swift in Sources */, + 37E6D79D2944AE1A00550C3D /* SubscriptionsViewModel.swift in Sources */, 37732FF52703D32400F04329 /* Sidebar.swift in Sources */, 379775942689365600DD52A8 /* Array+Next.swift in Sources */, 377ABC49286E5887009C986F /* Sequence+Unique.swift in Sources */, @@ -3296,6 +3329,7 @@ 37732FF12703A26300F04329 /* AccountValidationStatus.swift in Sources */, 37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */, 37CC3F46270CE30600608308 /* PlayerQueueItem.swift in Sources */, + 37E6D7A12944CD3800550C3D /* CacheStatusHeader.swift in Sources */, 37B2631B2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */, 3700155C271B0D4D0049C794 /* PipedAPI.swift in Sources */, 376BE50C27349108009AD608 /* BrowsingSettings.swift in Sources */, @@ -3312,6 +3346,7 @@ 374924E4292141320017D862 /* InspectorView.swift in Sources */, 375168D72700FDB8008F96A6 /* Debounce.swift in Sources */, 37D526DF2720AC4400ED2F5E /* VideosAPI.swift in Sources */, + 377F9F802944175F0043F856 /* FeedCacheModel.swift in Sources */, 373C8FE5275B955100CB5936 /* CommentsPage.swift in Sources */, 37D4B0E52671614900C925CA /* YatteeApp.swift in Sources */, 37130A60277657300033018A /* PersistenceController.swift in Sources */, @@ -3475,6 +3510,7 @@ 37C0697C2725C09E00F7F6CB /* PlayerQueueItemBridge.swift in Sources */, 3718B9A12921A9640003DB2E /* VideoDetails.swift in Sources */, 378AE93D274EDFB3006A4EE1 /* Backport.swift in Sources */, + 377F9F812944175F0043F856 /* FeedCacheModel.swift in Sources */, 37130A5D277657090033018A /* Yattee.xcdatamodeld in Sources */, 37C3A243272359900087A57A /* Double+Format.swift in Sources */, 37AAF29226740715007FC770 /* Channel.swift in Sources */, @@ -3589,11 +3625,13 @@ 37152EEC26EFEB95004FB96D /* LazyView.swift in Sources */, 37EF9A78275BEB8E0043B585 /* CommentView.swift in Sources */, 37484C2726FC83E000287258 /* InstanceForm.swift in Sources */, + 37E6D7A22944CD3800550C3D /* CacheStatusHeader.swift in Sources */, 37F49BA826CB0FCE00304AC0 /* PlaylistFormView.swift in Sources */, 37F0F4F0286F734400C06C2E /* AdvancedSettings.swift in Sources */, 373197DA2732060100EF734F /* RelatedView.swift in Sources */, 37DD9DA52785BBC900539416 /* NoCommentsView.swift in Sources */, 377ABC4A286E5887009C986F /* Sequence+Unique.swift in Sources */, + 37E6D79E2944AE1A00550C3D /* SubscriptionsViewModel.swift in Sources */, 37D4B19926717E1500C925CA /* Video.swift in Sources */, 378E50FD26FE8B9F00F49626 /* Instance.swift in Sources */, 37169AA82729E2CC0011DE61 /* AccountsBridge.swift in Sources */, @@ -3699,6 +3737,7 @@ MARKETING_VERSION = 1.4.3; PRODUCT_BUNDLE_IDENTIFIER = "stream.yattee.app.Open-in-Yattee"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -4732,6 +4771,16 @@ package = 374D11E52943C56300CB4350 /* XCRemoteSwiftPackageReference "Cache" */; productName = Cache; }; + 377F9F82294417B40043F856 /* Cache */ = { + isa = XCSwiftPackageProductDependency; + package = 374D11E52943C56300CB4350 /* XCRemoteSwiftPackageReference "Cache" */; + productName = Cache; + }; + 377F9F84294417FA0043F856 /* SwiftyJSON */ = { + isa = XCSwiftPackageProductDependency; + package = 37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */; + productName = SwiftyJSON; + }; 377FC7D4267A080300A6BBAF /* SwiftyJSON */ = { isa = XCSwiftPackageProductDependency; package = 37D4B19B2671817900C925CA /* XCRemoteSwiftPackageReference "SwiftyJSON" */;