Files
yattee/Yattee/Views/Components/VideoInfoComponents.swift
Arkadiusz Fal fd41833532 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
2026-02-12 05:47:43 +01:00

170 lines
5.5 KiB
Swift

//
// VideoInfoComponents.swift
// Yattee
//
// Shared components for displaying video information in player views.
//
import SwiftUI
// MARK: - Video Stats Row
/// Displays video statistics: view count, date, likes, and dislikes.
/// Uses NotificationCenter to ensure updates propagate across separate window context.
struct VideoStatsRow: View {
let playerState: PlayerState?
@Binding var showFormattedDate: Bool
let returnYouTubeDislikeEnabled: Bool
// State to force re-render when notification received
@State private var refreshTrigger: Int = 0
private var video: Video? { playerState?.currentVideo }
private var dislikeCount: Int? { playerState?.dislikeCount }
/// Whether API stats are actively loading (show placeholders only in this case).
private var isLoadingAPIStats: Bool {
guard let video = playerState?.currentVideo else { return false }
return video.supportsAPIStats && playerState?.videoDetailsState == .loading
}
var body: some View {
if let video {
statsContent(video, dislikeCount: dislikeCount)
.id(refreshTrigger) // Force view recreation
.onReceive(NotificationCenter.default.publisher(for: .videoDetailsDidLoad)) { _ in
refreshTrigger += 1
}
}
}
@ViewBuilder
private func statsContent(_ video: Video, dislikeCount: Int?) -> some View {
HStack(spacing: 4) {
// Date
if showFormattedDate, let publishedAt = video.publishedAt {
Text(publishedAt.formatted(date: .long, time: .omitted))
.onTapGesture { showFormattedDate.toggle() }
} else if let publishedText = video.formattedPublishedDate {
Text(publishedText)
.onTapGesture { showFormattedDate.toggle() }
} else if isLoadingAPIStats {
Text(verbatim: "2 weeks ago")
.redacted(reason: .placeholder)
}
// View count
if let viewCount = video.formattedViewCount {
Text(verbatim: "")
Text("video.views \(viewCount)")
} else if isLoadingAPIStats {
Text(verbatim: "")
Text("video.views 1.2M")
.redacted(reason: .placeholder)
}
Spacer()
// Like count
if let likeCount = video.likeCount {
CompactLabel(text: CountFormatter.compact(likeCount), systemImage: "hand.thumbsup")
} else if isLoadingAPIStats {
CompactLabel(text: "2.5K", systemImage: "hand.thumbsup")
.redacted(reason: .placeholder)
}
// Dislike count
if returnYouTubeDislikeEnabled {
if let dislikeCount {
CompactLabel(text: CountFormatter.compact(dislikeCount), systemImage: "hand.thumbsdown")
} else if isLoadingAPIStats {
CompactLabel(text: "500", systemImage: "hand.thumbsdown")
.redacted(reason: .placeholder)
}
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: - Video Channel Row
/// 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?
let onChannelTap: (() -> Void)?
let video: Video
let accentColor: Color
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 {
Button {
onChannelTap()
} label: {
channelContent
}
.buttonStyle(.plain)
} else {
channelContent
}
Spacer()
#if !os(tvOS)
VideoContextMenuView(
video: video,
accentColor: accentColor
)
#endif
}
.padding(.top, 4)
}
private var channelContent: some View {
HStack(spacing: 10) {
ChannelAvatarView(
author: enrichedAuthor,
size: 40,
yatteeServerURL: yatteeServerURL,
source: source
)
VStack(alignment: .leading, spacing: 2) {
Text(enrichedAuthor.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
if showSubscriberCount {
Group {
if let subscribers = enrichedAuthor.formattedSubscriberCount {
Text(subscribers)
} else if isLoadingDetails && video.supportsAPIStats {
Text("1.2M subscribers")
.redacted(reason: .placeholder)
}
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
}