diff --git a/Model/Cache/ChannelPlaylistsCacheModel.swift b/Model/Cache/ChannelPlaylistsCacheModel.swift new file mode 100644 index 00000000..1b2f0ac6 --- /dev/null +++ b/Model/Cache/ChannelPlaylistsCacheModel.swift @@ -0,0 +1,70 @@ +import Cache +import Foundation +import Logging +import SwiftyJSON + +struct ChannelPlaylistsCacheModel { + static let shared = ChannelPlaylistsCacheModel() + let logger = Logger(label: "stream.yattee.cache.channel-playlists") + + static let diskConfig = DiskConfig(name: "channel-playlists") + static let memoryConfig = MemoryConfig() + + let storage = try! Storage<String, JSON>( + diskConfig: Self.diskConfig, + memoryConfig: Self.memoryConfig, + transformer: CacheModel.jsonTransformer + ) + + func storePlaylist(playlist: ChannelPlaylist) { + let date = CacheModel.shared.iso8601DateFormatter.string(from: Date()) + logger.info("STORE \(playlistCacheKey(playlist.id)) -- \(date)") + let feedTimeObject: JSON = ["date": date] + let playlistObject: JSON = ["playlist": playlist.json.object] + try? storage.setObject(feedTimeObject, forKey: playlistTimeCacheKey(playlist.id)) + try? storage.setObject(playlistObject, forKey: playlistCacheKey(playlist.id)) + } + + func retrievePlaylist(_ id: ChannelPlaylist.ID) -> ChannelPlaylist? { + logger.info("RETRIEVE \(playlistCacheKey(id))") + + if let json = try? storage.object(forKey: playlistCacheKey(id)).dictionaryValue["playlist"] { + return ChannelPlaylist.from(json) + } + + return nil + } + + func getPlaylistsTime(_ id: ChannelPlaylist.ID) -> Date? { + if let json = try? storage.object(forKey: playlistTimeCacheKey(id)), + let string = json.dictionaryValue["date"]?.string, + let date = CacheModel.shared.iso8601DateFormatter.date(from: string) + { + return date + } + + return nil + } + + func getFormattedPlaylistTime(_ id: ChannelPlaylist.ID) -> String { + if let time = getPlaylistsTime(id) { + let isSameDay = Calendar(identifier: .iso8601).isDate(time, inSameDayAs: Date()) + let formatter = isSameDay ? CacheModel.shared.dateFormatterForTimeOnly : CacheModel.shared.dateFormatter + return formatter.string(from: time) + } + + return "" + } + + func clear() { + try? storage.removeAll() + } + + private func playlistCacheKey(_ playlist: ChannelPlaylist.ID) -> String { + "channelplaylists-\(playlist)" + } + + private func playlistTimeCacheKey(_ playlist: ChannelPlaylist.ID) -> String { + "\(playlistCacheKey(playlist))-time" + } +} diff --git a/Model/ChannelPlaylist.swift b/Model/ChannelPlaylist.swift index c10104ac..444dc10e 100644 --- a/Model/ChannelPlaylist.swift +++ b/Model/ChannelPlaylist.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftyJSON struct ChannelPlaylist: Identifiable { var id: String = UUID().uuidString @@ -7,4 +8,26 @@ struct ChannelPlaylist: Identifiable { var channel: Channel? var videos = [Video]() var videosCount: Int? + + var json: JSON { + [ + "id": id, + "title": title, + "thumbnailURL": thumbnailURL?.absoluteString ?? "", + "channel": channel?.json.object ?? "", + "videos": videos.map { $0.json.object }, + "videosCount": String(videosCount ?? 0) + ] + } + + static func from(_ json: JSON) -> Self { + ChannelPlaylist( + id: json["id"].stringValue, + title: json["title"].stringValue, + thumbnailURL: json["thumbnailURL"].url, + channel: Channel.from(json["channel"]), + videos: json["videos"].arrayValue.map { Video.from($0) }, + videosCount: json["videosCount"].int + ) + } } diff --git a/Model/Playlist.swift b/Model/Playlist.swift index dc74e159..95a8fd31 100644 --- a/Model/Playlist.swift +++ b/Model/Playlist.swift @@ -40,8 +40,6 @@ struct Playlist: Identifiable, Equatable, Hashable { } var json: JSON { - let dateFormatter = ISO8601DateFormatter() - return [ "id": id, "title": title, @@ -53,8 +51,6 @@ struct Playlist: Identifiable, Equatable, Hashable { } static func from(_ json: JSON) -> Self { - let dateFormatter = ISO8601DateFormatter() - return .init( id: json["id"].stringValue, title: json["title"].stringValue, @@ -71,4 +67,8 @@ struct Playlist: Identifiable, Equatable, Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } + + var channelPlaylist: ChannelPlaylist { + ChannelPlaylist(id: id, title: title, videos: videos, videosCount: videos.count) + } } diff --git a/Shared/Playlists/PlaylistsView.swift b/Shared/Playlists/PlaylistsView.swift index b01816ec..90d9bf06 100644 --- a/Shared/Playlists/PlaylistsView.swift +++ b/Shared/Playlists/PlaylistsView.swift @@ -94,19 +94,19 @@ struct PlaylistsView: View { } .onAppear { model.load() - resource?.load() + loadResource() } .onChange(of: accounts.current) { _ in model.load(force: true) - resource?.load() + loadResource() } .onChange(of: currentPlaylist) { _ in channelPlaylist.clear() userPlaylist.clear() - resource?.load() + loadResource() } .onChange(of: model.reloadPlaylists) { _ in - resource?.load() + loadResource() } #if os(iOS) .refreshControl { refreshControl in @@ -154,7 +154,7 @@ struct PlaylistsView: View { #if !os(macOS) .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in model.load() - resource?.loadIfNeeded() + loadResource() } #endif #if !os(tvOS) @@ -168,6 +168,26 @@ struct PlaylistsView: View { #endif } + func loadResource() { + loadCachedResource() + resource?.load() + .onSuccess { response in + if let playlist: Playlist = response.typedContent() { + ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist.channelPlaylist) + } + } + } + + func loadCachedResource() { + if !selectedPlaylistID.isEmpty, + let cache = ChannelPlaylistsCacheModel.shared.retrievePlaylist(selectedPlaylistID) + { + DispatchQueue.main.async { + self.channelPlaylist.replace(cache) + } + } + } + #if os(iOS) var playlistsMenu: some View { Menu { diff --git a/Shared/Views/PlaylistVideosView.swift b/Shared/Views/PlaylistVideosView.swift index dfd6c577..1f482287 100644 --- a/Shared/Views/PlaylistVideosView.swift +++ b/Shared/Views/PlaylistVideosView.swift @@ -2,7 +2,7 @@ import Siesta import SwiftUI struct PlaylistVideosView: View { - let playlist: Playlist + var playlist: Playlist @ObservedObject private var accounts = AccountsModel.shared var player = PlayerModel.shared @@ -43,6 +43,24 @@ struct PlaylistVideosView: View { return resource } + func loadResource() { + loadCachedResource() + resource?.load() + .onSuccess { response in + if let playlist: Playlist = response.typedContent() { + ChannelPlaylistsCacheModel.shared.storePlaylist(playlist: playlist.channelPlaylist) + } + } + } + + func loadCachedResource() { + if let cache = ChannelPlaylistsCacheModel.shared.retrievePlaylist(playlist.id) { + DispatchQueue.main.async { + self.channelPlaylist.replace(cache) + } + } + } + var videos: [Video] { contentItems.compactMap(\.video) } @@ -55,10 +73,10 @@ struct PlaylistVideosView: View { VerticalCells(items: contentItems) .onAppear { guard contentItems.isEmpty else { return } - resource?.load() + loadResource() } .onChange(of: model.reloadPlaylists) { _ in - resource?.load() + loadResource() } #if !os(tvOS) .navigationTitle("\(playlist.title) Playlist") diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 779514e8..c1ff91f1 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -537,6 +537,9 @@ 3776925229463C310055EC18 /* PlaylistsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776925129463C310055EC18 /* PlaylistsCacheModel.swift */; }; 3776925329463C310055EC18 /* PlaylistsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776925129463C310055EC18 /* PlaylistsCacheModel.swift */; }; 3776925429463C310055EC18 /* PlaylistsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776925129463C310055EC18 /* PlaylistsCacheModel.swift */; }; + 377692562946476F0055EC18 /* ChannelPlaylistsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377692552946476F0055EC18 /* ChannelPlaylistsCacheModel.swift */; }; + 377692572946476F0055EC18 /* ChannelPlaylistsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377692552946476F0055EC18 /* ChannelPlaylistsCacheModel.swift */; }; + 377692582946476F0055EC18 /* ChannelPlaylistsCacheModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377692552946476F0055EC18 /* ChannelPlaylistsCacheModel.swift */; }; 3776ADD6287381240078EBC4 /* Captions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776ADD5287381240078EBC4 /* Captions.swift */; }; 3776ADD7287381240078EBC4 /* Captions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776ADD5287381240078EBC4 /* Captions.swift */; }; 3776ADD8287381240078EBC4 /* Captions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776ADD5287381240078EBC4 /* Captions.swift */; }; @@ -1256,6 +1259,7 @@ 37737785276F9858000521C1 /* Windows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windows.swift; sourceTree = "<group>"; }; 3776924D294630110055EC18 /* ChannelAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelAvatarView.swift; sourceTree = "<group>"; }; 3776925129463C310055EC18 /* PlaylistsCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistsCacheModel.swift; sourceTree = "<group>"; }; + 377692552946476F0055EC18 /* ChannelPlaylistsCacheModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelPlaylistsCacheModel.swift; sourceTree = "<group>"; }; 3776ADD5287381240078EBC4 /* Captions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Captions.swift; path = Model/Captions.swift; sourceTree = SOURCE_ROOT; }; 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedContentAccessors.swift; sourceTree = "<group>"; }; 377ABC3F286E4AD5009C986F /* InstancesManifest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstancesManifest.swift; sourceTree = "<group>"; }; @@ -2022,6 +2026,7 @@ children = ( 3738535329451DC800D2D0CB /* BookmarksCacheModel.swift */, 37F5E8B9291BEF69006C15F5 /* CacheModel.swift */, + 377692552946476F0055EC18 /* ChannelPlaylistsCacheModel.swift */, 377F9F7E2944175F0043F856 /* FeedCacheModel.swift */, 3776925129463C310055EC18 /* PlaylistsCacheModel.swift */, 377F9F7A294403F20043F856 /* VideosCacheModel.swift */, @@ -3160,6 +3165,7 @@ 37EBD8C627AF26B300F1C24B /* AVPlayerBackend.swift in Sources */, 375E45F527B1976B00BA7902 /* MPVOGLView.swift in Sources */, 37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, + 377692562946476F0055EC18 /* ChannelPlaylistsCacheModel.swift in Sources */, 3769C02E2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */, 37F5E8B6291BE9D0006C15F5 /* URLBookmarkModel.swift in Sources */, 37579D5D27864F5F00FD0B98 /* Help.swift in Sources */, @@ -3300,6 +3306,7 @@ 378E50FC26FE8B9F00F49626 /* Instance.swift in Sources */, 37169AA32729D98A0011DE61 /* InstancesBridge.swift in Sources */, 37B044B826F7AB9000E1419D /* SettingsView.swift in Sources */, + 377692572946476F0055EC18 /* ChannelPlaylistsCacheModel.swift in Sources */, 374924E1292126A00017D862 /* VideoDetailsToolbar.swift in Sources */, 37F5E8BB291BEF69006C15F5 /* CacheModel.swift in Sources */, 3765788A2685471400D4EA09 /* Playlist.swift in Sources */, @@ -3563,6 +3570,7 @@ 37AAF29226740715007FC770 /* Channel.swift in Sources */, 37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, 376527BD285F60F700102284 /* PlayerTimeModel.swift in Sources */, + 377692582946476F0055EC18 /* ChannelPlaylistsCacheModel.swift in Sources */, 371B7E5E27596B8400D21217 /* Comment.swift in Sources */, 37732FF22703A26300F04329 /* AccountValidationStatus.swift in Sources */, 3756C2AC2861151C00E4B059 /* NetworkStateModel.swift in Sources */,