From c64f13a0e6b21a2265f9e9f9e2e0bd3fb3d45678 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 9 May 2026 15:00:53 +0200 Subject: [PATCH] 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. --- Yattee/Models/CachedChannelData.swift | 17 +++-- .../Navigation/NavigationCoordinator.swift | 1 + Yattee/Views/Channel/ChannelView.swift | 71 +++++++++---------- 3 files changed, 46 insertions(+), 43 deletions(-) diff --git a/Yattee/Models/CachedChannelData.swift b/Yattee/Models/CachedChannelData.swift index a39262cf..1bfb176f 100644 --- a/Yattee/Models/CachedChannelData.swift +++ b/Yattee/Models/CachedChannelData.swift @@ -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. diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index 56673e9c..083f3b0a 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -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)) } } diff --git a/Yattee/Views/Channel/ChannelView.swift b/Yattee/Views/Channel/ChannelView.swift index 105605a3..18602067 100644 --- a/Yattee/Views/Channel/ChannelView.swift +++ b/Yattee/Views/Channel/ChannelView.swift @@ -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()