mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user