mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
Chapter lists highlighted the active row by reading currentTime in the view body, re-rendering every row many times per second during playback. PlayerState now stores a change-guarded activeChapterID updated when time or chapters change, so views observe only chapter transitions.
169 lines
5.2 KiB
Swift
169 lines
5.2 KiB
Swift
//
|
|
// TVDetailsPanelPages.swift
|
|
// Yattee
|
|
//
|
|
// Related videos and chapters pages for the tvOS player details panel.
|
|
//
|
|
|
|
#if os(tvOS)
|
|
import SwiftUI
|
|
|
|
// MARK: - Related Videos
|
|
|
|
/// Focus-based list of videos related to the current one. Selecting a video
|
|
/// plays it immediately and dismisses the panel.
|
|
struct TVRelatedPanelList: View {
|
|
let video: Video?
|
|
let onDidSelectVideo: () -> Void
|
|
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
private var relatedVideos: [Video] { video?.relatedVideos ?? [] }
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 20) {
|
|
ForEach(relatedVideos) { relatedVideo in
|
|
TVRelatedVideoRow(video: relatedVideo) {
|
|
playVideo(relatedVideo)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 24)
|
|
}
|
|
}
|
|
|
|
private func playVideo(_ video: Video) {
|
|
guard let playerService = appEnvironment?.playerService else { return }
|
|
Task {
|
|
await playerService.play(video: video)
|
|
}
|
|
onDidSelectVideo()
|
|
}
|
|
}
|
|
|
|
/// Focusable row for a related video: thumbnail, title, channel.
|
|
private struct TVRelatedVideoRow: View {
|
|
let video: Video
|
|
let onTap: () -> Void
|
|
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack(spacing: 16) {
|
|
DeArrowVideoThumbnail(
|
|
video: video,
|
|
cornerRadius: 6,
|
|
duration: video.formattedDuration
|
|
)
|
|
.frame(width: 160, height: 90)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.lineLimit(2)
|
|
.foregroundStyle(.white)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
Text(video.author.name)
|
|
.font(.caption)
|
|
.foregroundStyle(.white.opacity(0.6))
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(TVCommentButtonStyle(hasReplies: false))
|
|
}
|
|
}
|
|
|
|
// MARK: - Chapters
|
|
|
|
/// Focus-based chapter list; selecting a chapter seeks and keeps the panel
|
|
/// open so nearby chapters stay reachable.
|
|
struct TVChaptersPanelList: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
private var playerService: PlayerService? { appEnvironment?.playerService }
|
|
private var playerState: PlayerState? { playerService?.state }
|
|
|
|
private var chapters: [VideoChapter] { playerState?.chapters ?? [] }
|
|
|
|
var body: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(spacing: 12) {
|
|
ForEach(chapters) { chapter in
|
|
TVChapterRow(
|
|
chapter: chapter,
|
|
isActive: isChapterActive(chapter),
|
|
onTap: {
|
|
Task {
|
|
await playerService?.seek(to: chapter.startTime)
|
|
}
|
|
}
|
|
)
|
|
.id(chapter.id)
|
|
}
|
|
}
|
|
.padding(.vertical, 24)
|
|
}
|
|
.onAppear {
|
|
if let active = chapters.first(where: { isChapterActive($0) }) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
proxy.scrollTo(active.id, anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isChapterActive(_ chapter: VideoChapter) -> Bool {
|
|
// Observes only chapter transitions, not per-tick currentTime updates.
|
|
playerState?.activeChapterID == chapter.id
|
|
}
|
|
}
|
|
|
|
/// Focusable row for a chapter: title, start time, and an active indicator.
|
|
private struct TVChapterRow: View {
|
|
let chapter: VideoChapter
|
|
let isActive: Bool
|
|
let onTap: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack(spacing: 16) {
|
|
Text(chapter.formattedStartTime)
|
|
.font(.subheadline)
|
|
.monospacedDigit()
|
|
.foregroundStyle(isActive ? AnyShapeStyle(.tint) : AnyShapeStyle(.white.opacity(0.6)))
|
|
.frame(width: 110, alignment: .leading)
|
|
|
|
Text(chapter.title)
|
|
.font(.subheadline)
|
|
.fontWeight(isActive ? .semibold : .regular)
|
|
.lineLimit(2)
|
|
.foregroundStyle(.white)
|
|
.multilineTextAlignment(.leading)
|
|
|
|
Spacer()
|
|
|
|
if isActive {
|
|
Image(systemName: "play.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.tint)
|
|
}
|
|
}
|
|
.padding(.vertical, 12)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(TVCommentButtonStyle(hasReplies: false))
|
|
}
|
|
}
|
|
|
|
#endif
|