Persist author cache to disk for instant channel info across restarts

Back the in-memory authorCache with a JSON file in ~/Library/Caches/AuthorCache/.
Disk is lazy-loaded on first lookup and saved asynchronously on each cache update.
Capped at 500 entries to prevent unbounded growth.

- Cache author data from video detail API responses (PlayerService, VideoInfoView)
- Replace ChannelView's private CachedChannelHeader with shared CachedChannelData
- Enrich author with cached avatar/subscriber count in VideoChannelRow, TVDetailsPanel, VideoInfoView
This commit is contained in:
Arkadiusz Fal
2026-02-12 05:47:43 +01:00
parent d1d7edb5ec
commit fd41833532
6 changed files with 185 additions and 37 deletions

View File

@@ -8,25 +8,6 @@
import SwiftUI
import NukeUI
/// Cached channel data for showing header immediately while loading.
private struct CachedChannelHeader {
let name: String
let thumbnailURL: URL?
let bannerURL: URL?
init(from subscription: Subscription) {
name = subscription.name
thumbnailURL = subscription.avatarURL
bannerURL = subscription.bannerURL
}
init(from recentChannel: RecentChannel) {
name = recentChannel.name
thumbnailURL = recentChannel.thumbnailURLString.flatMap { URL(string: $0) }
bannerURL = nil // RecentChannel doesn't store banner
}
}
struct ChannelView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@@ -52,7 +33,7 @@ struct ChannelView: View {
@State private var showingUnsubscribeConfirmation = false
@State private var scrollOffset: CGFloat = 0
@State private var scrollToTop: Bool = false
@State private var cachedHeader: CachedChannelHeader?
@State private var cachedHeader: CachedChannelData?
// View options (persisted)
@AppStorage("channel.layout") private var layout: VideoListLayout = .list
@@ -393,7 +374,7 @@ struct ChannelView: View {
/// Shows cached header with a spinner below while loading full channel data.
@ViewBuilder
private func loadingContent(_ cached: CachedChannelHeader) -> some View {
private func loadingContent(_ cached: CachedChannelData) -> some View {
GeometryReader { geometry in
ScrollView {
LazyVStack(spacing: 0) {
@@ -1526,11 +1507,7 @@ struct ChannelView: View {
isSubscribed = subscription != nil
// Load cached header data for immediate display
if let subscription {
cachedHeader = CachedChannelHeader(from: subscription)
} else if let recentChannel = appEnvironment.dataManager.recentChannelEntry(forChannelID: channelID) {
cachedHeader = CachedChannelHeader(from: recentChannel)
}
cachedHeader = CachedChannelData.load(for: channelID, using: appEnvironment.dataManager)
// Fetch channel and videos independently to handle partial failures gracefully
async let channelTask: Result<Channel, Error> = await {
@@ -1576,7 +1553,7 @@ struct ChannelView: View {
id: ChannelID(source: source, channelID: channelID),
name: cached.name,
description: nil,
subscriberCount: nil,
subscriberCount: cached.subscriberCount,
thumbnailURL: cached.thumbnailURL,
bannerURL: cached.bannerURL
)

View File

@@ -92,6 +92,8 @@ struct VideoStatsRow: View {
/// Displays channel info with avatar, name, subscriber count, and context menu.
struct VideoChannelRow: View {
@Environment(\.appEnvironment) private var appEnvironment
let author: Author
let source: ContentSource
let yatteeServerURL: URL?
@@ -101,6 +103,12 @@ struct VideoChannelRow: View {
var showSubscriberCount: Bool = true
var isLoadingDetails: Bool = false
/// Author enriched with cached channel data (avatar, subscriber count) from local stores.
private var enrichedAuthor: Author {
guard let dataManager = appEnvironment?.dataManager else { return author }
return author.enriched(using: dataManager)
}
var body: some View {
HStack(spacing: 12) {
if let onChannelTap {
@@ -129,21 +137,21 @@ struct VideoChannelRow: View {
private var channelContent: some View {
HStack(spacing: 10) {
ChannelAvatarView(
author: author,
author: enrichedAuthor,
size: 40,
yatteeServerURL: yatteeServerURL,
source: source
)
VStack(alignment: .leading, spacing: 2) {
Text(author.name)
Text(enrichedAuthor.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
if showSubscriberCount {
Group {
if let subscribers = author.formattedSubscriberCount {
if let subscribers = enrichedAuthor.formattedSubscriberCount {
Text(subscribers)
} else if isLoadingDetails && video.supportsAPIStats {
Text("1.2M subscribers")

View File

@@ -152,10 +152,17 @@ struct TVDetailsPanel: View {
// MARK: - Channel Row
/// Author enriched with cached channel data (avatar, subscriber count) from local stores.
private var enrichedAuthor: Author? {
guard let video else { return nil }
guard let dataManager = appEnvironment?.dataManager else { return video.author }
return video.author.enriched(using: dataManager)
}
private var channelRow: some View {
HStack(spacing: 16) {
// Channel avatar
if let thumbnailURL = video?.author.thumbnailURL {
if let thumbnailURL = enrichedAuthor?.thumbnailURL {
AsyncImage(url: thumbnailURL) { image in
image
.resizable()
@@ -179,12 +186,12 @@ struct TVDetailsPanel: View {
VStack(alignment: .leading, spacing: 4) {
// Channel name
Text(video?.author.name ?? "")
Text(enrichedAuthor?.name ?? "")
.font(.headline)
.foregroundStyle(.white)
// Subscriber count
if let subscriberCount = video?.author.subscriberCount {
if let subscriberCount = enrichedAuthor?.subscriberCount {
Text("channel.subscriberCount \(CountFormatter.compact(subscriberCount))")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.6))

View File

@@ -765,21 +765,22 @@ struct VideoInfoView: View {
/// Channel row content used in both tappable and non-tappable variants
private func channelRowContent(for video: Video) -> some View {
HStack(spacing: 10) {
let enrichedAuthor = appEnvironment.map { video.author.enriched(using: $0.dataManager) } ?? video.author
return HStack(spacing: 10) {
ChannelAvatarView(
author: video.author,
author: enrichedAuthor,
size: 40,
yatteeServerURL: yatteeServerURL,
source: video.authorSource
)
VStack(alignment: .leading, spacing: 2) {
Text(video.author.name)
Text(enrichedAuthor.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
if let subscribers = video.author.formattedSubscriberCount {
if let subscribers = enrichedAuthor.formattedSubscriberCount {
Text(subscribers)
.font(.caption)
.foregroundStyle(.secondary)
@@ -1784,6 +1785,7 @@ struct VideoInfoView: View {
// Use extractURL method - just use the video part
let (fullVideo, _, _) = try await contentService.extractURL(originalURL, instance: instance)
loadedVideoDetails[videoID] = fullVideo
CachedChannelData.cacheAuthor(fullVideo.author)
} catch {
// Fail silently - use partial video data we have
}
@@ -1807,6 +1809,7 @@ struct VideoInfoView: View {
instance: instance
)
loadedVideoDetails[videoID] = fullVideo
CachedChannelData.cacheAuthor(fullVideo.author)
} catch {
// Fail silently - just use the partial video data we have
}