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 bannerURL: URL?
let subscriberCount: Int?
let description: String?
/// In-memory cache of author data from video detail API responses.
@MainActor
@@ -32,21 +33,25 @@ struct CachedChannelData: Codable {
.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.thumbnailURL = thumbnailURL
self.bannerURL = bannerURL
self.subscriberCount = subscriberCount
self.description = description
}
@MainActor
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(
name: author.name,
thumbnailURL: author.thumbnailURL,
bannerURL: nil,
subscriberCount: author.subscriberCount
thumbnailURL: author.thumbnailURL ?? existing?.thumbnailURL,
bannerURL: existing?.bannerURL,
subscriberCount: author.subscriberCount ?? existing?.subscriberCount,
description: existing?.description
)
// Evict oldest entries if over limit
@@ -101,6 +106,7 @@ struct CachedChannelData: Codable {
thumbnailURL = subscription.avatarURL
bannerURL = subscription.bannerURL
subscriberCount = subscription.subscriberCount
description = subscription.channelDescription
}
init(from recentChannel: RecentChannel) {
@@ -108,6 +114,7 @@ struct CachedChannelData: Codable {
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.

View File

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

View File

@@ -571,18 +571,33 @@ struct ChannelView: View {
@ViewBuilder
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) {
tvOSAvatar(for: channel)
tvOSAvatar(name: name, thumbnailURL: thumbnailURL)
.frame(maxWidth: .infinity)
Text(channel.name)
Text(name)
.font(.title2)
.fontWeight(.bold)
.foregroundStyle(.white)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
if let subscriberCount = channel.subscriberCount {
if let subscriberCount {
Text(CountFormatter.compact(subscriberCount))
.fontWeight(.bold)
+ Text(verbatim: " ")
@@ -593,7 +608,7 @@ struct ChannelView: View {
tvOSSubscribeButton
}
if let description = channel.description, !description.isEmpty {
if let description, !description.isEmpty {
TVScrollableDescription(
description: description,
isScrollLocked: $isDescriptionScrollLocked
@@ -610,7 +625,12 @@ struct ChannelView: View {
@ViewBuilder
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 {
image
.resizable()
@@ -619,7 +639,7 @@ struct ChannelView: View {
Circle()
.fill(.ultraThinMaterial)
.overlay {
Text(String(channel.name.prefix(1)))
Text(String(name.prefix(1)))
.font(.system(size: 56, weight: .semibold))
.foregroundStyle(.secondary)
}
@@ -772,39 +792,14 @@ struct ChannelView: View {
GeometryReader { geometry in
let leftWidth = geometry.size.width * 0.30
HStack(alignment: .top, spacing: 40) {
VStack(alignment: .leading, spacing: 24) {
LazyImage(url: cached.thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.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)
}
tvOSLeftColumnContent(
name: cached.name,
thumbnailURL: cached.thumbnailURL,
subscriberCount: cached.subscriberCount,
description: cached.description
)
.frame(width: leftWidth, alignment: .leading)
.focusSection()
VStack {
Spacer()