Files
yattee/Yattee/Models/CachedChannelData.swift
Arkadiusz Fal c64f13a0e6 Show cached channel header on tvOS while channel loads
Render subscriber count, Subscribe button, and (for subscribed
channels) description in the tvOS loading state instead of just
avatar + name + spinner. Seed the in-memory author cache when
navigating to a channel from a video so the first-time channel
view has a name and avatar to display immediately.
2026-05-09 15:00:53 +02:00

160 lines
5.5 KiB
Swift

//
// CachedChannelData.swift
// Yattee
//
// Cached channel data from Subscription or RecentChannel for instant display.
//
import Foundation
/// Cached channel data loaded from local SwiftData stores (Subscription or RecentChannel).
/// Used to show channel info immediately while API responses are loading.
struct CachedChannelData: Codable {
let name: String
let thumbnailURL: URL?
let bannerURL: URL?
let subscriberCount: Int?
let description: String?
/// In-memory cache of author data from video detail API responses.
@MainActor
private static var authorCache: [String: CachedChannelData] = [:]
/// Whether the disk cache has been loaded into memory.
@MainActor
private static var diskLoaded = false
/// Maximum number of cached author entries.
private static let maxCacheSize = 500
private static var cacheFileURL: URL {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
return caches.appendingPathComponent("AuthorCache", isDirectory: true)
.appendingPathComponent("authors.json")
}
init(name: String, thumbnailURL: URL?, bannerURL: URL?, subscriberCount: Int?, description: String? = nil) {
self.name = name
self.thumbnailURL = thumbnailURL
self.bannerURL = bannerURL
self.subscriberCount = subscriberCount
self.description = description
}
@MainActor
static func cacheAuthor(_ author: Author) {
guard !author.id.isEmpty, !author.name.isEmpty else { return }
loadFromDiskIfNeeded()
let existing = authorCache[author.id]
authorCache[author.id] = CachedChannelData(
name: author.name,
thumbnailURL: author.thumbnailURL ?? existing?.thumbnailURL,
bannerURL: existing?.bannerURL,
subscriberCount: author.subscriberCount ?? existing?.subscriberCount,
description: existing?.description
)
// Evict oldest entries if over limit
if authorCache.count > maxCacheSize {
let excess = authorCache.count - maxCacheSize
let keysToRemove = Array(authorCache.keys.prefix(excess))
for key in keysToRemove {
authorCache.removeValue(forKey: key)
}
}
saveToDisk()
}
// MARK: - Disk Persistence
@MainActor
private static func loadFromDiskIfNeeded() {
guard !diskLoaded else { return }
diskLoaded = true
let url = cacheFileURL
guard FileManager.default.fileExists(atPath: url.path) else { return }
do {
let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode([String: CachedChannelData].self, from: data)
// Only fill entries not already present in memory
for (key, value) in decoded where authorCache[key] == nil {
authorCache[key] = value
}
} catch {
try? FileManager.default.removeItem(at: url)
}
}
@MainActor
private static func saveToDisk() {
let snapshot = authorCache
Task.detached(priority: .utility) {
let url = cacheFileURL
let directory = url.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
if let data = try? JSONEncoder().encode(snapshot) {
try? data.write(to: url, options: .atomic)
}
}
}
init(from subscription: Subscription) {
name = subscription.name
thumbnailURL = subscription.avatarURL
bannerURL = subscription.bannerURL
subscriberCount = subscription.subscriberCount
description = subscription.channelDescription
}
init(from recentChannel: RecentChannel) {
name = recentChannel.name
thumbnailURL = recentChannel.thumbnailURLString.flatMap { URL(string: $0) }
bannerURL = nil // RecentChannel doesn't store banner
subscriberCount = recentChannel.subscriberCount
description = nil
}
/// Load cached data for a channel ID from Subscription or RecentChannel.
@MainActor
static func load(for channelID: String, using dataManager: DataManager) -> CachedChannelData? {
loadFromDiskIfNeeded()
if let subscription = dataManager.subscription(for: channelID) {
return CachedChannelData(from: subscription)
}
if let recentChannel = dataManager.recentChannelEntry(forChannelID: channelID) {
return CachedChannelData(from: recentChannel)
}
// Finally, check in-memory cache from video detail API responses
return authorCache[channelID]
}
}
// MARK: - Author Enrichment
extension Author {
/// Returns a new Author with missing fields filled in from cached channel data.
func enriched(from cached: CachedChannelData) -> Author {
Author(
id: id,
name: name,
thumbnailURL: thumbnailURL ?? cached.thumbnailURL,
subscriberCount: subscriberCount ?? cached.subscriberCount,
instance: instance,
url: url,
hasRealChannelInfo: hasRealChannelInfo
)
}
/// Convenience: looks up cached data for this author's ID and enriches if found.
@MainActor
func enriched(using dataManager: DataManager) -> Author {
guard let cached = CachedChannelData.load(for: id, using: dataManager) else {
return self
}
return enriched(from: cached)
}
}