mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
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
170 lines
5.5 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|