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.
This commit is contained in:
Arkadiusz Fal
2026-05-09 15:00:53 +02:00
parent 1f0f3a8cf0
commit c64f13a0e6
3 changed files with 46 additions and 43 deletions

View File

@@ -14,6 +14,7 @@ struct CachedChannelData: Codable {
let thumbnailURL: URL? let thumbnailURL: URL?
let bannerURL: URL? let bannerURL: URL?
let subscriberCount: Int? let subscriberCount: Int?
let description: String?
/// In-memory cache of author data from video detail API responses. /// In-memory cache of author data from video detail API responses.
@MainActor @MainActor
@@ -32,21 +33,25 @@ struct CachedChannelData: Codable {
.appendingPathComponent("authors.json") .appendingPathComponent("authors.json")
} }
init(name: String, thumbnailURL: URL?, bannerURL: URL?, subscriberCount: Int?) { init(name: String, thumbnailURL: URL?, bannerURL: URL?, subscriberCount: Int?, description: String? = nil) {
self.name = name self.name = name
self.thumbnailURL = thumbnailURL self.thumbnailURL = thumbnailURL
self.bannerURL = bannerURL self.bannerURL = bannerURL
self.subscriberCount = subscriberCount self.subscriberCount = subscriberCount
self.description = description
} }
@MainActor @MainActor
static func cacheAuthor(_ author: Author) { static func cacheAuthor(_ author: Author) {
guard author.thumbnailURL != nil || author.subscriberCount != nil else { return } guard !author.id.isEmpty, !author.name.isEmpty else { return }
loadFromDiskIfNeeded()
let existing = authorCache[author.id]
authorCache[author.id] = CachedChannelData( authorCache[author.id] = CachedChannelData(
name: author.name, name: author.name,
thumbnailURL: author.thumbnailURL, thumbnailURL: author.thumbnailURL ?? existing?.thumbnailURL,
bannerURL: nil, bannerURL: existing?.bannerURL,
subscriberCount: author.subscriberCount subscriberCount: author.subscriberCount ?? existing?.subscriberCount,
description: existing?.description
) )
// Evict oldest entries if over limit // Evict oldest entries if over limit
@@ -101,6 +106,7 @@ struct CachedChannelData: Codable {
thumbnailURL = subscription.avatarURL thumbnailURL = subscription.avatarURL
bannerURL = subscription.bannerURL bannerURL = subscription.bannerURL
subscriberCount = subscription.subscriberCount subscriberCount = subscription.subscriberCount
description = subscription.channelDescription
} }
init(from recentChannel: RecentChannel) { init(from recentChannel: RecentChannel) {
@@ -108,6 +114,7 @@ struct CachedChannelData: Codable {
thumbnailURL = recentChannel.thumbnailURLString.flatMap { URL(string: $0) } thumbnailURL = recentChannel.thumbnailURLString.flatMap { URL(string: $0) }
bannerURL = nil // RecentChannel doesn't store banner bannerURL = nil // RecentChannel doesn't store banner
subscriberCount = recentChannel.subscriberCount subscriberCount = recentChannel.subscriberCount
description = nil
} }
/// Load cached data for a channel ID from Subscription or RecentChannel. /// Load cached data for a channel ID from Subscription or RecentChannel.

View File

@@ -333,6 +333,7 @@ final class NavigationCoordinator {
} else if case .extracted = video.id.source, let authorURL = video.author.url { } else if case .extracted = video.id.source, let authorURL = video.author.url {
navigate(to: .externalChannel(authorURL)) navigate(to: .externalChannel(authorURL))
} else { } else {
CachedChannelData.cacheAuthor(video.author)
navigate(to: .channel(video.author.id, video.authorSource)) navigate(to: .channel(video.author.id, video.authorSource))
} }
} }

View File

@@ -571,18 +571,33 @@ struct ChannelView: View {
@ViewBuilder @ViewBuilder
private func tvOSLeftColumn(channel: Channel, geometry: GeometryProxy) -> some View { private func tvOSLeftColumn(channel: Channel, geometry: GeometryProxy) -> some View {
tvOSLeftColumnContent(
name: channel.name,
thumbnailURL: channel.thumbnailURL,
subscriberCount: channel.subscriberCount,
description: channel.description
)
}
@ViewBuilder
private func tvOSLeftColumnContent(
name: String,
thumbnailURL: URL?,
subscriberCount: Int?,
description: String?
) -> some View {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
tvOSAvatar(for: channel) tvOSAvatar(name: name, thumbnailURL: thumbnailURL)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
Text(channel.name) Text(name)
.font(.title2) .font(.title2)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundStyle(.white) .foregroundStyle(.white)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
if let subscriberCount = channel.subscriberCount { if let subscriberCount {
Text(CountFormatter.compact(subscriberCount)) Text(CountFormatter.compact(subscriberCount))
.fontWeight(.bold) .fontWeight(.bold)
+ Text(verbatim: " ") + Text(verbatim: " ")
@@ -593,7 +608,7 @@ struct ChannelView: View {
tvOSSubscribeButton tvOSSubscribeButton
} }
if let description = channel.description, !description.isEmpty { if let description, !description.isEmpty {
TVScrollableDescription( TVScrollableDescription(
description: description, description: description,
isScrollLocked: $isDescriptionScrollLocked isScrollLocked: $isDescriptionScrollLocked
@@ -610,7 +625,12 @@ struct ChannelView: View {
@ViewBuilder @ViewBuilder
private func tvOSAvatar(for channel: Channel) -> some View { private func tvOSAvatar(for channel: Channel) -> some View {
LazyImage(url: channel.thumbnailURL) { state in tvOSAvatar(name: channel.name, thumbnailURL: channel.thumbnailURL)
}
@ViewBuilder
private func tvOSAvatar(name: String, thumbnailURL: URL?) -> some View {
LazyImage(url: thumbnailURL) { state in
if let image = state.image { if let image = state.image {
image image
.resizable() .resizable()
@@ -619,7 +639,7 @@ struct ChannelView: View {
Circle() Circle()
.fill(.ultraThinMaterial) .fill(.ultraThinMaterial)
.overlay { .overlay {
Text(String(channel.name.prefix(1))) Text(String(name.prefix(1)))
.font(.system(size: 56, weight: .semibold)) .font(.system(size: 56, weight: .semibold))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -772,39 +792,14 @@ struct ChannelView: View {
GeometryReader { geometry in GeometryReader { geometry in
let leftWidth = geometry.size.width * 0.30 let leftWidth = geometry.size.width * 0.30
HStack(alignment: .top, spacing: 40) { HStack(alignment: .top, spacing: 40) {
VStack(alignment: .leading, spacing: 24) { tvOSLeftColumnContent(
LazyImage(url: cached.thumbnailURL) { state in name: cached.name,
if let image = state.image { thumbnailURL: cached.thumbnailURL,
image subscriberCount: cached.subscriberCount,
.resizable() description: cached.description
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.ultraThinMaterial)
.overlay {
Text(String(cached.name.prefix(1)))
.font(.system(size: 56, weight: .semibold))
.foregroundStyle(.secondary)
}
}
}
.frame(width: 140, height: 140)
.clipShape(Circle())
.overlay(
Circle()
.stroke(.white.opacity(0.8), lineWidth: 3)
) )
.frame(maxWidth: .infinity)
Text(cached.name)
.font(.title2)
.fontWeight(.bold)
.foregroundStyle(.white)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 0)
}
.frame(width: leftWidth, alignment: .leading) .frame(width: leftWidth, alignment: .leading)
.focusSection()
VStack { VStack {
Spacer() Spacer()