Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,176 @@
//
// TVAutoplayCountdownView.swift
// Yattee
//
// Autoplay countdown overlay for tvOS - shows countdown and next video preview.
//
#if os(tvOS)
import SwiftUI
import NukeUI
/// Autoplay countdown overlay for tvOS player.
/// Shows countdown timer and next video preview with options to play immediately or cancel.
struct TVAutoplayCountdownView: View {
let countdown: Int
let nextVideo: QueuedVideo
let onPlayNext: () -> Void
let onCancel: () -> Void
@FocusState private var focusedButton: CountdownButton?
enum CountdownButton: Hashable {
case playNext
case cancel
}
var body: some View {
ZStack {
// Dark overlay background
Color.black.opacity(0.4)
.ignoresSafeArea()
VStack(spacing: 30) {
// Countdown text
Text(String(localized: "player.autoplay.playingIn \(countdown)"))
.font(.system(size: 48, weight: .semibold))
.monospacedDigit()
.foregroundStyle(.white)
// Next video preview card
nextVideoCard
.focusable()
.scaleEffect(focusedButton == nil ? 1.05 : 1.0)
.animation(.easeInOut(duration: 0.15), value: focusedButton)
// Action buttons
HStack(spacing: 40) {
playNextButton
cancelButton
}
.focusSection()
}
}
.onAppear {
// Set default focus to Play Next
focusedButton = .playNext
}
}
// MARK: - Next Video Card
private var nextVideoCard: some View {
HStack(spacing: 20) {
// Thumbnail
LazyImage(url: nextVideo.video.bestThumbnail?.url) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Color.gray.opacity(0.3)
}
}
.frame(width: 280, height: 158)
.clipShape(RoundedRectangle(cornerRadius: 12))
// Video info
VStack(alignment: .leading, spacing: 8) {
Text(nextVideo.video.title)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(2)
.multilineTextAlignment(.leading)
Text(nextVideo.video.author.name)
.font(.system(size: 18))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
// Duration badge if available
if nextVideo.video.duration > 0 {
Text(formatDuration(nextVideo.video.duration))
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(.white.opacity(0.2))
)
.padding(.top, 4)
}
}
.frame(maxWidth: 400, alignment: .leading)
}
.padding(20)
.frame(width: 720)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.white.opacity(0.1))
)
}
// MARK: - Buttons
private var playNextButton: some View {
Button {
onPlayNext()
} label: {
Text(String(localized: "player.autoplay.playNext"))
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 250, height: 80)
}
.buttonStyle(TVCountdownButtonStyle())
.focused($focusedButton, equals: .playNext)
}
private var cancelButton: some View {
Button {
onCancel()
} label: {
Text(String(localized: "player.autoplay.cancel"))
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 250, height: 80)
}
.buttonStyle(TVCountdownButtonStyle())
.focused($focusedButton, equals: .cancel)
}
// MARK: - Helpers
private func formatDuration(_ seconds: TimeInterval) -> String {
let totalSeconds = Int(seconds)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let secs = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, secs)
} else {
return String(format: "%d:%02d", minutes, secs)
}
}
}
// MARK: - Button Style
/// Button style for countdown action buttons (Play Next, Cancel).
struct TVCountdownButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isFocused ? .white.opacity(0.3) : .white.opacity(0.15))
)
.scaleEffect(configuration.isPressed ? 0.95 : (isFocused ? 1.05 : 1.0))
.animation(.easeInOut(duration: 0.15), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
#endif

View File

@@ -0,0 +1,883 @@
//
// TVDetailsPanel.swift
// Yattee
//
// Swipe-up details panel for tvOS player showing video info and comments.
//
#if os(tvOS)
import SwiftUI
import NukeUI
/// Details panel that slides up from the bottom showing video information.
struct TVDetailsPanel: View {
let video: Video?
let onDismiss: () -> Void
@Environment(\.appEnvironment) private var appEnvironment
/// Tab selection for Info / Comments.
@State private var selectedTab: TVDetailsTab = .info
/// Focus state for interactive elements.
@FocusState private var focusedItem: TVDetailsFocusItem?
/// Whether description scroll is locked (prevents focus from leaving description).
@State private var isDescriptionScrollLocked = false
/// Comments state
@State private var comments: [Comment] = []
@State private var commentsState: CommentsLoadState = .idle
@State private var commentsContinuation: String?
var body: some View {
VStack(spacing: 0) {
// Drag indicator
Capsule()
.fill(.white.opacity(0.4))
.frame(width: 80, height: 6)
.padding(.top, 20)
// Tab picker (hidden when description scroll is locked)
if !isDescriptionScrollLocked {
Picker("", selection: $selectedTab) {
Text("Info").tag(TVDetailsTab.info)
if video?.supportsComments == true {
Text("Comments").tag(TVDetailsTab.comments)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 120)
.padding(.top, 24)
.padding(.bottom, 20)
.focused($focusedItem, equals: .tabPicker)
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Content based on selected tab
Group {
switch selectedTab {
case .info:
infoContent
case .comments:
commentsContent
}
}
.padding(.horizontal, 88)
Spacer()
}
.frame(maxWidth: .infinity)
.frame(height: UIScreen.main.bounds.height * 0.65)
.background(
RoundedRectangle(cornerRadius: 32, style: .continuous)
.fill(.ultraThinMaterial)
.ignoresSafeArea(edges: .bottom)
)
.frame(maxHeight: .infinity, alignment: .bottom)
.onExitCommand {
// If description scroll is locked, unlock it first
if isDescriptionScrollLocked {
withAnimation(.easeInOut(duration: 0.15)) {
isDescriptionScrollLocked = false
}
} else {
onDismiss()
}
}
.onAppear {
focusedItem = .tabPicker
}
.onChange(of: selectedTab) { _, newTab in
// Reset scroll lock when switching tabs
if isDescriptionScrollLocked {
isDescriptionScrollLocked = false
}
// Reset comments state when switching to comments tab
if newTab == .comments && commentsState == .idle {
// Comments will load via TVCommentsListView's .task
}
}
.onChange(of: video?.supportsComments) { _, supportsComments in
// If current video doesn't support comments, switch to info tab
if supportsComments == false && selectedTab == .comments {
selectedTab = .info
}
}
}
// MARK: - Info Content
private var infoContent: some View {
VStack(spacing: 0) {
// Top section with title, channel, stats (hidden when description expanded)
if !isDescriptionScrollLocked {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Video title
Text(video?.title ?? "")
.font(.title2)
.fontWeight(.semibold)
.lineLimit(3)
.foregroundStyle(.white)
// Channel info row
channelRow
// Stats row
statsRow
}
.padding(.vertical, 16)
}
.frame(height: 180)
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Description - expands to fill when locked
if let description = video?.description, !description.isEmpty {
TVScrollableDescription(
description: description,
focusedItem: $focusedItem,
isScrollLocked: $isDescriptionScrollLocked
)
.padding(.top, isDescriptionScrollLocked ? 24 : 8)
}
if isDescriptionScrollLocked {
Spacer()
}
}
.animation(.easeInOut(duration: 0.25), value: isDescriptionScrollLocked)
}
// MARK: - Channel Row
private var channelRow: some View {
HStack(spacing: 16) {
// Channel avatar
if let thumbnailURL = video?.author.thumbnailURL {
AsyncImage(url: thumbnailURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(.white.opacity(0.2))
}
.frame(width: 64, height: 64)
.clipShape(Circle())
} else {
Circle()
.fill(.white.opacity(0.2))
.frame(width: 64, height: 64)
.overlay {
Image(systemName: "person.fill")
.font(.title2)
.foregroundStyle(.white.opacity(0.5))
}
}
VStack(alignment: .leading, spacing: 4) {
// Channel name
Text(video?.author.name ?? "")
.font(.headline)
.foregroundStyle(.white)
// Subscriber count
if let subscriberCount = video?.author.subscriberCount {
Text("\(CountFormatter.compact(subscriberCount)) subscribers")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.6))
}
}
Spacer()
// Channel button
Button {
// Navigate to channel
if let video {
navigateToChannel(video.author)
}
} label: {
Text("View Channel")
.font(.callout)
.fontWeight(.medium)
}
.buttonStyle(.bordered)
.focused($focusedItem, equals: .channelButton)
}
}
// MARK: - Stats Row
private var statsRow: some View {
HStack(spacing: 32) {
// Views
if let viewCount = video?.viewCount {
Label {
Text(CountFormatter.compact(viewCount))
} icon: {
Image(systemName: "eye")
}
}
// Likes
if let likeCount = video?.likeCount {
Label {
Text(CountFormatter.compact(likeCount))
} icon: {
Image(systemName: "hand.thumbsup")
}
}
// Published date
if let publishedText = video?.formattedPublishedDate {
Label {
Text(publishedText)
} icon: {
Image(systemName: "calendar")
}
}
// Duration
if let video, video.duration > 0 {
Label {
Text(video.formattedDuration)
} icon: {
Image(systemName: "clock")
}
}
// Live indicator
if video?.isLive == true {
Label("LIVE", systemImage: "dot.radiowaves.left.and.right")
.foregroundStyle(.red)
}
}
.font(.subheadline)
.foregroundStyle(.white.opacity(0.7))
}
// MARK: - Comments Content
private var commentsContent: some View {
ScrollView {
if let videoID = video?.id.videoID {
TVCommentsListView(
videoID: videoID,
comments: $comments,
commentsState: $commentsState,
commentsContinuation: $commentsContinuation
)
.padding(.vertical, 16)
} else {
Text("Comments unavailable")
.font(.body)
.foregroundStyle(.white.opacity(0.5))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 40)
}
}
}
// MARK: - Navigation
private func navigateToChannel(_ author: Author) {
// Close the player and navigate to channel
onDismiss()
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
// Navigate to channel view
// This would need to be implemented based on your navigation system
}
}
// MARK: - Supporting Types
/// Tabs for the details panel.
enum TVDetailsTab: String, CaseIterable {
case info
case comments
}
/// Focus items for the details panel.
enum TVDetailsFocusItem: Hashable {
case tabPicker
case channelButton
case description
}
/// Scrollable description view with click-to-lock scrolling.
/// When locked, expands to fill available space for easier reading.
struct TVScrollableDescription: View {
let description: String
@FocusState.Binding var focusedItem: TVDetailsFocusItem?
@Binding var isScrollLocked: Bool
@State private var scrollOffset: CGFloat = 0
private let scrollStep: CGFloat = 80
private let maxScroll: CGFloat = 5000
/// Height of description area - expands when locked
private var descriptionHeight: CGFloat {
isScrollLocked ? 500 : 200
}
private var isFocused: Bool {
focusedItem == .description
}
var body: some View {
Button {
// Toggle scroll lock on click/select
withAnimation(.easeInOut(duration: 0.25)) {
isScrollLocked.toggle()
if !isScrollLocked {
scrollOffset = 0
}
}
} label: {
descriptionContent
}
.buttonStyle(TVDescriptionButtonStyle(isFocused: isFocused, isLocked: isScrollLocked))
.focused($focusedItem, equals: .description)
.onMoveCommand { direction in
guard isScrollLocked else { return }
switch direction {
case .down:
withAnimation(.easeOut(duration: 0.15)) {
scrollOffset = min(scrollOffset + scrollStep, maxScroll)
}
case .up:
withAnimation(.easeOut(duration: 0.15)) {
scrollOffset = max(scrollOffset - scrollStep, 0)
}
default:
break
}
}
.onChange(of: isFocused) { _, focused in
if !focused {
// Reset lock when losing focus
withAnimation(.easeInOut(duration: 0.2)) {
isScrollLocked = false
scrollOffset = 0
}
}
}
}
private var descriptionContent: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Description")
.font(.headline)
.foregroundStyle(.white.opacity(0.7))
Spacer()
if isFocused {
Text(isScrollLocked ? "↑↓ scroll • click to close" : "click to expand")
.font(.callout)
.foregroundStyle(.white.opacity(0.5))
}
}
// Clipped container for scrollable text
Text(description)
.font(.body)
.foregroundStyle(.white.opacity(0.9))
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading)
.offset(y: -scrollOffset)
.frame(height: descriptionHeight, alignment: .top)
.clipped()
}
.frame(maxWidth: .infinity, alignment: .leading)
.animation(.easeInOut(duration: 0.25), value: isScrollLocked)
}
}
/// Button style for description view - no default focus highlight.
struct TVDescriptionButtonStyle: ButtonStyle {
let isFocused: Bool
let isLocked: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(16)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isFocused ? (isLocked ? .white.opacity(0.2) : .white.opacity(0.1)) : .clear)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isLocked ? .white.opacity(0.5) : .clear, lineWidth: 2)
)
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
/// tvOS comments list with focusable comment rows.
/// Each comment is focusable to allow one-by-one navigation.
struct TVCommentsListView: View {
let videoID: String
@Binding var comments: [Comment]
@Binding var commentsState: CommentsLoadState
@Binding var commentsContinuation: String?
@Environment(\.appEnvironment) private var appEnvironment
private var contentService: ContentService? { appEnvironment?.contentService }
private var instancesManager: InstancesManager? { appEnvironment?.instancesManager }
private var canLoadMore: Bool {
commentsContinuation != nil && commentsState == .loaded
}
var body: some View {
Group {
switch commentsState {
case .idle, .loading:
loadingView
case .disabled:
disabledView
case .error:
errorView
case .loaded, .loadingMore:
if comments.isEmpty {
emptyView
} else {
commentsList
}
}
}
.task {
await loadComments()
}
}
private var loadingView: some View {
HStack {
Spacer()
ProgressView()
Spacer()
}
.padding(.vertical, 32)
}
private var disabledView: some View {
VStack(spacing: 8) {
Image(systemName: "bubble.left.and.exclamationmark.bubble.right")
.font(.title2)
.foregroundStyle(.white.opacity(0.5))
Text("Comments disabled")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.5))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
private var errorView: some View {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.title2)
.foregroundStyle(.white.opacity(0.5))
Text(String(localized: "comments.failedToLoad"))
.font(.subheadline)
.foregroundStyle(.white.opacity(0.5))
Button(String(localized: "common.retry")) {
Task { await loadComments() }
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
private var emptyView: some View {
VStack(spacing: 8) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.title2)
.foregroundStyle(.white.opacity(0.5))
Text("No comments")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.5))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
private var commentsList: some View {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(comments) { comment in
TVFocusableCommentView(comment: comment, videoID: videoID)
.onAppear {
// Load more when reaching near the end
if comment.id == comments.last?.id && canLoadMore {
Task { await loadMoreComments() }
}
}
if comment.id != comments.last?.id {
Divider()
.background(.white.opacity(0.2))
}
}
// Loading more indicator
if commentsState == .loadingMore {
HStack {
Spacer()
ProgressView()
.padding(.vertical, 16)
Spacer()
}
}
}
}
private func loadComments() async {
guard commentsState == .idle else { return }
guard let contentService, let instancesManager else { return }
guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return }
commentsState = .loading
do {
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: nil)
comments = page.comments
commentsContinuation = page.continuation
commentsState = .loaded
} catch let error as APIError where error == .commentsDisabled {
commentsState = .disabled
} catch {
commentsState = .error
}
}
private func loadMoreComments() async {
guard canLoadMore else { return }
guard let contentService, let instancesManager else { return }
guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return }
commentsState = .loadingMore
do {
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: commentsContinuation)
comments.append(contentsOf: page.comments)
commentsContinuation = page.continuation
commentsState = .loaded
} catch {
commentsState = .loaded
}
}
}
/// Focusable comment view for tvOS.
/// Comments with replies show an expandable button, others have invisible focus placeholder.
struct TVFocusableCommentView: View {
let comment: Comment
let videoID: String
@Environment(\.appEnvironment) private var appEnvironment
@State private var replies: [Comment] = []
@State private var isLoadingReplies = false
@State private var showReplies = false
@State private var repliesContinuation: String?
private var contentService: ContentService? { appEnvironment?.contentService }
private var instancesManager: InstancesManager? { appEnvironment?.instancesManager }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Main comment content - always focusable
Button {
// Toggle replies if available
if comment.replyCount > 0 {
withAnimation(.easeInOut(duration: 0.2)) {
if showReplies {
showReplies = false
} else {
showReplies = true
if replies.isEmpty {
Task { await loadReplies() }
}
}
}
}
} label: {
commentContent
}
.buttonStyle(TVCommentButtonStyle(hasReplies: comment.replyCount > 0))
// Replies section
if showReplies && comment.replyCount > 0 {
repliesSection
}
}
}
private var commentContent: some View {
HStack(alignment: .top, spacing: 12) {
// Author avatar
LazyImage(url: comment.author.thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.white.opacity(0.2))
.overlay {
Text(String(comment.author.name.prefix(1)))
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.white.opacity(0.5))
}
}
}
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 4) {
// Author name and badges
HStack(spacing: 4) {
Text(comment.author.name)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.white)
if comment.isCreatorComment {
Image(systemName: "checkmark.seal.fill")
.font(.caption)
.foregroundStyle(.blue)
}
if comment.isPinned {
Image(systemName: "pin.fill")
.font(.caption)
.foregroundStyle(.white.opacity(0.5))
}
Spacer()
// Published time
if let publishedText = comment.formattedPublishedDate {
Text(publishedText)
.font(.caption)
.foregroundStyle(.white.opacity(0.5))
}
}
// Comment content
Text(comment.content)
.font(.body)
.foregroundStyle(.white.opacity(0.9))
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
// Metadata row
HStack(spacing: 16) {
if let likeCount = comment.formattedLikeCount {
HStack(spacing: 4) {
Image(systemName: "hand.thumbsup")
.font(.caption)
Text(likeCount)
.font(.caption)
}
.foregroundStyle(.white.opacity(0.5))
}
if comment.hasCreatorHeart {
Image(systemName: "heart.fill")
.font(.caption)
.foregroundStyle(.red)
}
// Replies indicator
if comment.replyCount > 0 {
HStack(spacing: 4) {
Image(systemName: showReplies ? "chevron.up" : "chevron.down")
.font(.caption2)
Text("\(comment.replyCount) replies")
.font(.caption)
}
.foregroundStyle(.blue)
}
}
}
}
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var repliesSection: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(replies) { reply in
TVReplyView(comment: reply)
.padding(.leading, 52) // Indent replies
if reply.id != replies.last?.id {
Divider()
.background(.white.opacity(0.15))
.padding(.leading, 52)
}
}
// Loading indicator
if isLoadingReplies {
HStack {
Spacer()
ProgressView()
.padding(.vertical, 8)
Spacer()
}
.padding(.leading, 52)
}
// Load more button
if !isLoadingReplies && repliesContinuation != nil {
Button {
Task { await loadReplies() }
} label: {
Text("Load more replies")
.font(.caption)
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
.padding(.leading, 52)
.padding(.vertical, 8)
}
}
}
private func loadReplies() async {
guard let contentService, let instancesManager else { return }
guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return }
let continuation = replies.isEmpty ? comment.repliesContinuation : repliesContinuation
guard let continuation else { return }
isLoadingReplies = true
do {
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: continuation)
replies.append(contentsOf: page.comments)
repliesContinuation = page.continuation
} catch {
// Silently fail for replies
}
isLoadingReplies = false
}
}
/// Focusable reply view for tvOS.
struct TVReplyView: View {
let comment: Comment
var body: some View {
Button {
// No action for replies, just focusable for navigation
} label: {
replyContent
}
.buttonStyle(TVCommentButtonStyle(hasReplies: false))
}
private var replyContent: some View {
HStack(alignment: .top, spacing: 10) {
// Author avatar (smaller for replies)
LazyImage(url: comment.author.thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.white.opacity(0.2))
.overlay {
Text(String(comment.author.name.prefix(1)))
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.white.opacity(0.5))
}
}
}
.frame(width: 28, height: 28)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
// Author name
HStack(spacing: 4) {
Text(comment.author.name)
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.white)
if comment.isCreatorComment {
Image(systemName: "checkmark.seal.fill")
.font(.caption2)
.foregroundStyle(.blue)
}
if let publishedText = comment.formattedPublishedDate {
Text("\(publishedText)")
.font(.caption2)
.foregroundStyle(.white.opacity(0.5))
}
}
// Reply content
Text(comment.content)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.85))
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
// Likes
if let likeCount = comment.formattedLikeCount {
HStack(spacing: 4) {
Image(systemName: "hand.thumbsup")
.font(.caption2)
Text(likeCount)
.font(.caption2)
}
.foregroundStyle(.white.opacity(0.5))
.padding(.top, 2)
}
}
}
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Button style for focusable comments.
struct TVCommentButtonStyle: ButtonStyle {
let hasReplies: Bool
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isFocused ? .white.opacity(0.1) : .clear)
)
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
.animation(.easeInOut(duration: 0.15), value: isFocused)
}
}
#endif

View File

@@ -0,0 +1,379 @@
//
// TVPlayerControlsView.swift
// Yattee
//
// AVKit-style player controls overlay for tvOS with focus-based navigation.
//
#if os(tvOS)
import SwiftUI
/// AVKit-style player controls overlay for tvOS.
struct TVPlayerControlsView: View {
@Environment(\.appEnvironment) private var appEnvironment
let playerState: PlayerState?
let playerService: PlayerService?
@FocusState.Binding var focusedControl: TVPlayerFocusTarget?
let onShowDetails: () -> Void
let onShowQuality: () -> Void
let onShowDebug: () -> Void
let onDismiss: () -> Void
/// Called when scrubbing state changes - parent should stop auto-hide timer when true
var onScrubbingChanged: ((Bool) -> Void)?
/// Whether to show in-app volume controls (only when volume mode is .mpv)
private var showVolumeControls: Bool {
GlobalLayoutSettings.cached.volumeMode == .mpv
}
@State private var playNextTapCount = 0
@State private var seekBackwardTrigger = 0
@State private var seekForwardTrigger = 0
var body: some View {
ZStack {
// Gradient overlay for readability
gradientOverlay
VStack(spacing: 0) {
// Top bar with title and channel
topBar
.padding(.top, 60)
.padding(.horizontal, 88)
Spacer()
// Center transport controls - focus section for horizontal nav
transportControls
.focusSection()
// DEBUG: Uncomment to see focus section boundaries
// .border(.blue, width: 2)
Spacer()
// Progress bar - its own focus section
TVPlayerProgressBar(
currentTime: playerState?.currentTime ?? 0,
duration: playerState?.duration ?? 0,
bufferedTime: playerState?.bufferedTime ?? 0,
storyboard: playerState?.preferredStoryboard,
chapters: playerState?.chapters ?? [],
onSeek: { time in
Task {
await playerService?.seek(to: time)
}
},
onScrubbingChanged: onScrubbingChanged,
isLive: playerState?.isLive ?? false,
sponsorSegments: playerState?.sponsorSegments ?? []
)
.focusSection()
.padding(.horizontal, 88)
.padding(.bottom, 20)
// DEBUG: Uncomment to see focus section boundaries
// .border(.green, width: 2)
// Action buttons row - focus section for horizontal nav
actionButtons
.focusSection()
.padding(.horizontal, 88)
.padding(.bottom, 60)
// DEBUG: Uncomment to see focus section boundaries
// .border(.red, width: 2)
}
}
}
// MARK: - Gradient Overlay
private var gradientOverlay: some View {
VStack(spacing: 0) {
// Top gradient
LinearGradient(
colors: [.black.opacity(0.8), .black.opacity(0.4), .clear],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 200)
Spacer()
// Bottom gradient
LinearGradient(
colors: [.clear, .black.opacity(0.4), .black.opacity(0.8)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 300)
}
.ignoresSafeArea()
}
// MARK: - Top Bar
private var topBar: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 8) {
// Video title
Text(playerState?.currentVideo?.title ?? "")
.font(.title2)
.fontWeight(.semibold)
.lineLimit(2)
.foregroundStyle(.white)
// Channel name
if let channelName = playerState?.currentVideo?.author.name {
Text(channelName)
.font(.headline)
.foregroundStyle(.white.opacity(0.7))
}
}
Spacer()
// Loading indicator
if playerState?.playbackState == .loading || playerState?.playbackState == .buffering {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(1.5)
}
}
}
// MARK: - Transport Controls
private var transportControls: some View {
HStack(spacing: 80) {
// Skip backward
Button {
seekBackwardTrigger += 1
playerService?.seekBackward(by: 10)
} label: {
Image(systemName: "10.arrow.trianglehead.counterclockwise")
.font(.system(size: 52, weight: .medium))
.symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekBackwardTrigger)
}
.buttonStyle(TVTransportButtonStyle())
.focused($focusedControl, equals: .skipBackward)
.disabled(isTransportDisabled)
// Play/Pause - hide when transport disabled, show spacer to maintain layout
if !isTransportDisabled {
Button {
playerService?.togglePlayPause()
} label: {
Image(systemName: playPauseIcon)
.font(.system(size: 72, weight: .medium))
.contentTransition(.symbolEffect(.replace, options: .speed(2)))
}
.buttonStyle(TVTransportButtonStyle())
.focused($focusedControl, equals: .playPause)
} else {
// Invisible spacer maintains layout stability
Color.clear
.frame(width: 72, height: 72)
.allowsHitTesting(false)
}
// Skip forward
Button {
seekForwardTrigger += 1
playerService?.seekForward(by: 10)
} label: {
Image(systemName: "10.arrow.trianglehead.clockwise")
.font(.system(size: 52, weight: .medium))
.symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekForwardTrigger)
}
.buttonStyle(TVTransportButtonStyle())
.focused($focusedControl, equals: .skipForward)
.disabled(isTransportDisabled)
}
}
/// Whether transport controls should be disabled (during loading/buffering or buffer not ready)
private var isTransportDisabled: Bool {
playerState?.playbackState == .loading ||
playerState?.playbackState == .buffering ||
!(playerState?.isFirstFrameReady ?? false) ||
!(playerState?.isBufferReady ?? false)
}
private var playPauseIcon: String {
switch playerState?.playbackState {
case .playing:
return "pause.fill"
default:
return "play.fill"
}
}
// MARK: - Action Buttons
private var actionButtons: some View {
HStack(spacing: 40) {
// Quality selector
Button {
onShowQuality()
} label: {
VStack(spacing: 6) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 28))
Text("Quality")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .qualityButton)
// Captions
Button {
// TODO: Show captions picker
} label: {
VStack(spacing: 6) {
Image(systemName: "captions.bubble")
.font(.system(size: 28))
Text("Subtitles")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .captionsButton)
// Debug overlay
Button {
onShowDebug()
} label: {
VStack(spacing: 6) {
Image(systemName: "ant.circle")
.font(.system(size: 28))
Text("Debug")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .debugButton)
// Info / Details
Button {
onShowDetails()
} label: {
VStack(spacing: 6) {
Image(systemName: "info.circle")
.font(.system(size: 28))
Text("Info")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .infoButton)
// Volume controls (only when in-app volume mode)
if showVolumeControls {
// Volume down
Button {
guard let state = playerState else { return }
let newVolume = max(0, state.volume - 0.1)
playerService?.currentBackend?.volume = newVolume
playerService?.state.volume = newVolume
appEnvironment?.settingsManager.playerVolume = newVolume
} label: {
VStack(spacing: 6) {
Image(systemName: "speaker.minus")
.font(.system(size: 28))
Text("Vol -")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .volumeDown)
// Volume up
Button {
guard let state = playerState else { return }
let newVolume = min(1.0, state.volume + 0.1)
playerService?.currentBackend?.volume = newVolume
playerService?.state.volume = newVolume
appEnvironment?.settingsManager.playerVolume = newVolume
} label: {
VStack(spacing: 6) {
Image(systemName: "speaker.plus")
.font(.system(size: 28))
Text("Vol +")
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .volumeUp)
}
// Play next button (when queue has items)
if let state = playerState, state.hasNext {
Button {
playNextTapCount += 1
Task { await playerService?.playNext() }
} label: {
VStack(spacing: 6) {
Image(systemName: "forward.fill")
.font(.system(size: 28))
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playNextTapCount)
Text(String(localized: "player.next"))
.font(.caption)
}
}
.buttonStyle(TVActionButtonStyle())
.focused($focusedControl, equals: .playNext)
}
Spacer()
// Queue indicator (if videos in queue)
if let state = playerState, state.hasNext {
HStack(spacing: 8) {
Image(systemName: "list.bullet")
.font(.system(size: 20))
Text(String(localized: "queue.section.count \(state.queue.count)"))
.font(.subheadline)
}
.foregroundStyle(.white.opacity(0.6))
}
}
}
}
// MARK: - Button Styles
/// Button style for transport controls (play/pause, skip).
struct TVTransportButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(.white)
.opacity(configuration.isPressed ? 0.6 : 1.0)
.scaleEffect(configuration.isPressed ? 0.9 : (isFocused ? 1.15 : 1.0))
.shadow(color: isFocused ? .white.opacity(0.5) : .clear, radius: 20)
.animation(.easeInOut(duration: 0.15), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
/// Button style for action buttons (quality, captions, info).
struct TVActionButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(.white)
.frame(width: 100, height: 80)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isFocused ? .white.opacity(0.3) : .white.opacity(0.1))
)
.scaleEffect(configuration.isPressed ? 0.95 : (isFocused ? 1.05 : 1.0))
.animation(.easeInOut(duration: 0.15), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
#endif

View File

@@ -0,0 +1,421 @@
//
// TVPlayerProgressBar.swift
// Yattee
//
// Focusable progress bar for tvOS with smooth touchpad scrubbing support.
//
#if os(tvOS)
import SwiftUI
import UIKit
/// Progress bar with smooth scrubbing support for tvOS Siri Remote touchpad.
struct TVPlayerProgressBar: View {
let currentTime: TimeInterval
let duration: TimeInterval
let bufferedTime: TimeInterval
let storyboard: Storyboard?
let chapters: [VideoChapter]
let onSeek: (TimeInterval) -> Void
/// Called when scrubbing state changes - parent should stop auto-hide timer when true
var onScrubbingChanged: ((Bool) -> Void)?
/// Whether the current stream is live
let isLive: Bool
/// Whether to show chapter markers on the progress bar (default: true)
var showChapters: Bool = true
/// SponsorBlock segments to display on the progress bar.
var sponsorSegments: [SponsorBlockSegment] = []
/// Settings for SponsorBlock segment display.
var sponsorBlockSettings: SponsorBlockSegmentSettings = .default
/// Color for the played portion of the progress bar.
var playedColor: Color = .red
/// Track focus state internally.
@FocusState private var isFocused: Bool
/// Time during active scrubbing (nil when not scrubbing).
@State private var scrubTime: TimeInterval?
/// Whether user is actively scrubbing.
@State private var isScrubbing = false
/// Accumulated pan translation for scrubbing.
@State private var panAccumulator: CGFloat = 0
/// The time to display (scrub time if scrubbing, else current time).
private var displayTime: TimeInterval {
scrubTime ?? currentTime
}
/// Progress as a fraction (0-1).
private var progress: Double {
guard duration > 0 else { return 0 }
return min(max(displayTime / duration, 0), 1)
}
/// Buffered progress as a fraction (0-1).
private var bufferedProgress: Double {
guard duration > 0 else { return 0 }
return min(bufferedTime / duration, 1)
}
var body: some View {
ZStack {
// Gesture capture layer (only when scrubbing)
if isScrubbing {
TVPanGestureView(
onPanChanged: { translation, velocity in
handlePan(translation: translation, velocity: velocity)
},
onPanEnded: {
handlePanEnded()
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// Visual content with button for click-to-scrub (disabled for live)
Button {
if isScrubbing {
commitScrub()
} else if !isLive {
enterScrubMode()
}
} label: {
progressContent
}
.buttonStyle(TVProgressBarButtonStyle(isFocused: isFocused))
.disabled(isLive)
}
.focused($isFocused)
.onMoveCommand { direction in
// D-pad fallback for scrubbing
if isScrubbing {
handleDPad(direction: direction)
}
}
.onChange(of: isFocused) { _, focused in
if !focused {
commitScrub()
}
}
.animation(.easeInOut(duration: 0.2), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: isScrubbing)
}
private func enterScrubMode() {
scrubTime = currentTime
panAccumulator = 0
withAnimation(.easeOut(duration: 0.15)) {
isScrubbing = true
}
onScrubbingChanged?(true)
}
private var progressContent: some View {
VStack(spacing: 12) {
// Progress bar (hide for live streams)
if !isLive {
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Progress bar with chapter segments (4pt gaps for tvOS visibility)
SegmentedProgressBar(
chapters: showChapters ? chapters : [],
duration: duration,
currentTime: displayTime,
bufferedTime: bufferedTime,
height: isFocused ? (isScrubbing ? 16 : 12) : 6,
gapWidth: 4,
playedColor: isFocused ? playedColor : .white,
bufferedColor: .white.opacity(0.4),
backgroundColor: .white.opacity(0.2),
sponsorSegments: sponsorSegments,
sponsorBlockSettings: sponsorBlockSettings
)
// Scrub handle (visible when focused)
if isFocused {
Circle()
.fill(.white)
.frame(width: isScrubbing ? 32 : 24, height: isScrubbing ? 32 : 24)
.shadow(color: .black.opacity(0.3), radius: 4)
.offset(x: (geometry.size.width * progress) - (isScrubbing ? 16 : 12))
.animation(.easeOut(duration: 0.1), value: progress)
}
}
}
.frame(height: isFocused ? (isScrubbing ? 16 : 12) : 6)
}
// Time labels
HStack {
// Current time or LIVE indicator
if isLive {
HStack(spacing: 6) {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
Text("LIVE")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.red)
}
} else {
Text(formatTime(displayTime))
.monospacedDigit()
.font(.subheadline)
.fontWeight(isScrubbing ? .semibold : .regular)
.foregroundStyle(.white)
}
Spacer()
// Scrub hint when focused (only for non-live)
if !isLive && isFocused && !isScrubbing {
Text("Press to scrub")
.font(.caption)
.foregroundStyle(.white.opacity(0.5))
} else if !isLive && isScrubbing {
Text("Swipe ← → • press to seek")
.font(.caption)
.foregroundStyle(.white.opacity(0.7))
}
Spacer()
// Remaining time (only for non-live)
if !isLive {
Text("-\(formatTime(max(0, duration - displayTime)))")
.monospacedDigit()
.font(.subheadline)
.foregroundStyle(.white.opacity(0.7))
}
}
// Scrub preview (storyboard thumbnail or large time display)
if isScrubbing {
if let storyboard {
TVSeekPreviewView(
storyboard: storyboard,
seekTime: scrubTime ?? currentTime,
chapters: showChapters ? chapters : []
)
.transition(.scale.combined(with: .opacity))
} else {
// Fallback when no storyboard available
Text(formatTime(scrubTime ?? currentTime))
.font(.system(size: 48, weight: .medium))
.monospacedDigit()
.foregroundStyle(.white)
.padding(.horizontal, 32)
.padding(.vertical, 16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.ultraThinMaterial)
)
.transition(.scale.combined(with: .opacity))
}
}
}
}
// MARK: - Pan Gesture Handling
private func handlePan(translation: CGFloat, velocity: CGFloat) {
guard duration > 0, isScrubbing else { return }
// Cancel any pending seek when user starts new pan
seekTask?.cancel()
// Calculate scrub sensitivity based on duration
// Lower values = slower/finer scrubbing
let baseSensitivity: CGFloat
if duration > 3600 {
baseSensitivity = duration / 2000 // ~1.8 sec per unit for 1hr video
} else if duration > 600 {
baseSensitivity = duration / 3000 // ~0.2 sec per unit for 10min video
} else {
baseSensitivity = duration / 4000 // very fine control for short videos
}
// Apply velocity multiplier (faster swipe = faster scrub)
// Reduced range: 0.3x to 2.0x
let velocityMultiplier = min(max(abs(velocity) / 800, 0.3), 2.0)
let adjustedSensitivity = baseSensitivity * velocityMultiplier
// Update scrub time based on translation delta
let delta = translation - panAccumulator
panAccumulator = translation
let timeChange = TimeInterval(delta * adjustedSensitivity)
let currentScrubTime = scrubTime ?? currentTime
scrubTime = min(max(0, currentScrubTime + timeChange), duration)
}
private func handlePanEnded() {
// Reset accumulator for next swipe
panAccumulator = 0
// Schedule debounced seek but stay in scrub mode
scheduleSeek()
}
@State private var seekTask: Task<Void, Never>?
private func scheduleSeek() {
seekTask?.cancel()
seekTask = Task {
try? await Task.sleep(for: .milliseconds(1000))
guard !Task.isCancelled else { return }
await MainActor.run {
if let time = scrubTime {
onSeek(time)
}
}
}
}
// MARK: - D-Pad Fallback
private func handleDPad(direction: MoveCommandDirection) {
guard duration > 0, isScrubbing else { return }
switch direction {
case .left, .right:
// Determine scrub increment based on video length
let scrubAmount: TimeInterval
if duration > 3600 {
scrubAmount = 30
} else if duration > 600 {
scrubAmount = 15
} else {
scrubAmount = 10
}
let currentScrubTime = scrubTime ?? currentTime
if direction == .left {
scrubTime = max(0, currentScrubTime - scrubAmount)
} else {
scrubTime = min(duration, currentScrubTime + scrubAmount)
}
case .up, .down:
// Exit scrub mode and let navigation happen
commitScrub()
@unknown default:
break
}
}
// MARK: - Commit
private func commitScrub() {
seekTask?.cancel()
seekTask = nil
let wasScrubbing = isScrubbing
if let time = scrubTime {
onSeek(time)
}
withAnimation(.easeOut(duration: 0.15)) {
scrubTime = nil
isScrubbing = false
}
panAccumulator = 0
if wasScrubbing {
onScrubbingChanged?(false)
}
}
// MARK: - Formatting
private func formatTime(_ time: TimeInterval) -> String {
let totalSeconds = Int(max(0, time))
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%d:%02d", minutes, seconds)
}
}
// MARK: - Pan Gesture View
/// UIKit view that captures pan gestures on the Siri Remote touchpad.
struct TVPanGestureView: UIViewRepresentable {
let onPanChanged: (CGFloat, CGFloat) -> Void // (translation, velocity)
let onPanEnded: () -> Void
func makeUIView(context: Context) -> TVPanGestureUIView {
let view = TVPanGestureUIView()
view.onPanChanged = onPanChanged
view.onPanEnded = onPanEnded
return view
}
func updateUIView(_ uiView: TVPanGestureUIView, context: Context) {
uiView.onPanChanged = onPanChanged
uiView.onPanEnded = onPanEnded
}
}
class TVPanGestureUIView: UIView {
var onPanChanged: ((CGFloat, CGFloat) -> Void)?
var onPanEnded: (() -> Void)?
private var panRecognizer: UIPanGestureRecognizer!
override init(frame: CGRect) {
super.init(frame: frame)
setupGesture()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupGesture()
}
private func setupGesture() {
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
panRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)]
addGestureRecognizer(panRecognizer)
// Make view focusable
isUserInteractionEnabled = true
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: self).x
let velocity = gesture.velocity(in: self).x
switch gesture.state {
case .began, .changed:
onPanChanged?(translation, velocity)
case .ended, .cancelled:
onPanEnded?()
gesture.setTranslation(.zero, in: self)
default:
break
}
}
}
// MARK: - Button Style
/// Button style for the progress bar.
struct TVProgressBarButtonStyle: ButtonStyle {
let isFocused: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
.opacity(configuration.isPressed ? 0.9 : 1.0)
}
}
#endif

View File

@@ -0,0 +1,558 @@
//
// TVPlayerView.swift
// Yattee
//
// Main tvOS player container with custom controls and Apple TV remote support.
//
#if os(tvOS)
import SwiftUI
/// Focus targets for tvOS player controls navigation.
enum TVPlayerFocusTarget: Hashable {
case background // For capturing events when controls hidden
case skipBackward
case playPause
case skipForward
case progressBar
case qualityButton
case captionsButton
case debugButton
case infoButton
case volumeDown
case volumeUp
case playNext
}
/// Main tvOS fullscreen player view.
struct TVPlayerView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.dismiss) private var dismiss
// MARK: - State
/// Whether controls overlay is visible.
@State private var controlsVisible = true
/// Timer for auto-hiding controls.
@State private var controlsHideTimer: Timer?
/// Whether the details panel is shown.
@State private var isDetailsPanelVisible = false
/// Whether user is scrubbing the progress bar.
@State private var isScrubbing = false
/// Whether the quality sheet is shown.
@State private var showingQualitySheet = false
/// Whether the debug overlay is shown.
@State private var isDebugOverlayVisible = false
/// Debug statistics from MPV.
@State private var debugStats: MPVDebugStats = .init()
/// Timer for updating debug stats.
@State private var debugUpdateTimer: Timer?
/// Current focus target for D-pad navigation.
@FocusState private var focusedControl: TVPlayerFocusTarget?
/// Whether the autoplay countdown is visible.
@State private var showAutoplayCountdown = false
/// Current countdown value (5, 4, 3, 2, 1).
@State private var autoplayCountdown = 5
/// Timer for the countdown.
@State private var autoplayTimer: Timer?
// MARK: - Computed Properties
private var playerService: PlayerService? {
appEnvironment?.playerService
}
private var playerState: PlayerState? {
playerService?.state
}
// MARK: - Body
var body: some View {
mpvPlayerContent
.ignoresSafeArea()
.playerToastOverlay()
// Quality selector sheet
.sheet(isPresented: $showingQualitySheet) {
if let playerService {
let dashEnabled = appEnvironment?.settingsManager.dashEnabled ?? false
let supportedFormats = playerService.currentBackendType.supportedFormats
QualitySelectorView(
streams: playerService.availableStreams.filter { stream in
let format = StreamFormat.detect(from: stream)
if format == .dash && !dashEnabled {
return false
}
return supportedFormats.contains(format)
},
captions: playerService.availableCaptions,
currentStream: playerState?.currentStream,
currentAudioStream: playerState?.currentAudioStream,
currentCaption: playerService.currentCaption,
isLoading: playerState?.playbackState == .loading,
currentDownload: playerService.currentDownload,
isLoadingOnlineStreams: playerService.isLoadingOnlineStreams,
localCaptionURL: playerService.currentDownload.flatMap { download in
guard let path = download.localCaptionPath else { return nil }
return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path)
},
currentRate: playerState?.rate ?? .x1,
onStreamSelected: { stream, audioStream in
switchToStream(stream, audioStream: audioStream)
},
onCaptionSelected: { caption in
playerService.loadCaption(caption)
},
onLoadOnlineStreams: {
Task {
await playerService.loadOnlineStreams()
}
},
onSwitchToOnlineStream: { stream, audioStream in
Task {
await playerService.switchToOnlineStream(stream, audioStream: audioStream)
}
},
onRateChanged: { rate in
playerState?.rate = rate
playerService.currentBackend?.rate = Float(rate.rawValue)
}
)
}
}
}
// MARK: - MPV Content
/// Custom MPV player view with custom controls.
@ViewBuilder
private var mpvPlayerContent: some View {
ZStack {
// Background - always focusable to capture remote events
backgroundLayer
// Video layer
videoLayer
// Controls overlay
if controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible {
TVPlayerControlsView(
playerState: playerState,
playerService: playerService,
focusedControl: $focusedControl,
onShowDetails: { showDetailsPanel() },
onShowQuality: { showQualitySheet() },
onShowDebug: { showDebugOverlay() },
onDismiss: { dismissPlayer() },
onScrubbingChanged: { scrubbing in
isScrubbing = scrubbing
if scrubbing {
stopControlsTimer()
} else {
startControlsTimer()
}
}
)
.transition(.opacity.animation(.easeInOut(duration: 0.25)))
}
// Swipe-up details panel
if isDetailsPanelVisible {
TVDetailsPanel(
video: playerState?.currentVideo,
onDismiss: { hideDetailsPanel() }
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
// Debug overlay
if isDebugOverlayVisible {
MPVDebugOverlay(
stats: debugStats,
isVisible: $isDebugOverlayVisible,
isLandscape: true,
onClose: { hideDebugOverlay() }
)
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
// Autoplay countdown overlay
if showAutoplayCountdown, let nextVideo = playerState?.nextQueuedVideo {
TVAutoplayCountdownView(
countdown: autoplayCountdown,
nextVideo: nextVideo,
onPlayNext: { playNextInQueue() },
onCancel: { cancelAutoplay() }
)
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
.onAppear {
startControlsTimer()
focusedControl = .playPause
}
.onDisappear {
stopControlsTimer()
stopDebugUpdates()
stopAutoplayCountdown()
}
// Remote event handling - these work globally
.onPlayPauseCommand {
handlePlayPause()
}
.onExitCommand {
handleMenuButton()
}
// Track focus changes to show controls when navigating
.onChange(of: focusedControl) { oldValue, newValue in
handleFocusChange(from: oldValue, to: newValue)
}
// Start auto-hide timer when playback starts, handle video ended
.onChange(of: playerState?.playbackState) { _, newState in
if newState == .playing && controlsVisible && !isScrubbing {
startControlsTimer()
} else if newState == .ended {
handleVideoEnded()
}
}
// Dismiss countdown if video changes during countdown (e.g., from remote control)
.onChange(of: playerState?.currentVideo?.id) { _, _ in
if showAutoplayCountdown {
stopAutoplayCountdown()
showControls()
}
}
}
// MARK: - Background Layer
@ViewBuilder
private var backgroundLayer: some View {
if !controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible {
// When controls hidden, use a Button to capture both click and swipe
Button {
showControls()
} label: {
Color.black
.ignoresSafeArea()
}
.buttonStyle(TVBackgroundButtonStyle())
.focused($focusedControl, equals: .background)
.onMoveCommand { _ in
// Any direction press shows controls
showControls()
}
} else {
// When controls visible, just a plain background
Color.black
.ignoresSafeArea()
}
}
// MARK: - Video Layer
@ViewBuilder
private var videoLayer: some View {
if let playerService,
let backend = playerService.currentBackend as? MPVBackend,
let playerState {
MPVRenderViewRepresentable(
backend: backend,
playerState: playerState
)
.ignoresSafeArea()
.allowsHitTesting(false)
} else {
// Fallback/loading state - show thumbnail
if let video = playerState?.currentVideo,
let thumbnailURL = video.bestThumbnail?.url {
AsyncImage(url: thumbnailURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
Color.black
}
.allowsHitTesting(false)
}
}
}
// MARK: - Focus Handling
private func handleFocusChange(from oldValue: TVPlayerFocusTarget?, to newValue: TVPlayerFocusTarget?) {
// If focus moved to a control, ensure controls are visible
if let newValue, newValue != .background {
if !controlsVisible {
showControls()
}
startControlsTimer()
}
}
// MARK: - Controls Timer
private func startControlsTimer() {
stopControlsTimer()
// Don't auto-hide if paused
guard playerState?.playbackState == .playing else { return }
controlsHideTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in
Task { @MainActor in
withAnimation(.easeOut(duration: 0.3)) {
controlsVisible = false
focusedControl = .background
}
}
}
}
private func stopControlsTimer() {
controlsHideTimer?.invalidate()
controlsHideTimer = nil
}
private func showControls() {
withAnimation(.easeIn(duration: 0.2)) {
controlsVisible = true
}
if focusedControl == .background || focusedControl == nil {
focusedControl = .playPause
}
startControlsTimer()
}
// MARK: - Details Panel
private func showDetailsPanel() {
stopControlsTimer()
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
isDetailsPanelVisible = true
controlsVisible = false
}
}
// MARK: - Quality Sheet
private func showQualitySheet() {
stopControlsTimer()
showingQualitySheet = true
}
private func switchToStream(_ stream: Stream, audioStream: Stream? = nil) {
guard let video = playerState?.currentVideo else { return }
let currentTime = playerState?.currentTime
Task {
await playerService?.play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime)
}
}
private func hideDetailsPanel() {
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
isDetailsPanelVisible = false
}
showControls()
}
// MARK: - Debug Overlay
private func showDebugOverlay() {
stopControlsTimer()
startDebugUpdates()
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
isDebugOverlayVisible = true
controlsVisible = false
}
}
private func hideDebugOverlay() {
stopDebugUpdates()
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
isDebugOverlayVisible = false
}
showControls()
}
private func startDebugUpdates() {
stopDebugUpdates()
guard let backend = playerService?.currentBackend as? MPVBackend else { return }
// Update immediately
debugStats = backend.getDebugStats()
// Then update every second
debugUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
Task { @MainActor in
guard let backend = self.playerService?.currentBackend as? MPVBackend else { return }
self.debugStats = backend.getDebugStats()
}
}
}
private func stopDebugUpdates() {
debugUpdateTimer?.invalidate()
debugUpdateTimer = nil
}
// MARK: - Remote Event Handlers
private func handlePlayPause() {
// Cancel countdown if visible
if showAutoplayCountdown {
stopAutoplayCountdown()
showControls()
return
}
// Show controls if hidden (but not if debug overlay is visible), then toggle playback
if !controlsVisible && !isDetailsPanelVisible && !isDebugOverlayVisible {
showControls()
}
playerService?.togglePlayPause()
// Reset timer when interacting (only if controls are visible)
if !isDebugOverlayVisible {
if playerState?.playbackState == .playing {
startControlsTimer()
} else {
stopControlsTimer()
}
}
}
private func handleMenuButton() {
if showAutoplayCountdown {
// First priority: cancel countdown
cancelAutoplay()
} else if isDebugOverlayVisible {
// Second: hide debug overlay
hideDebugOverlay()
} else if isDetailsPanelVisible {
// Third: hide details panel
hideDetailsPanel()
} else if isScrubbing {
// Fourth: exit scrub mode (handled by progress bar losing focus)
// Just hide controls
hideControls()
} else if controlsVisible {
// Fifth: hide controls
hideControls()
} else {
// Sixth: dismiss player (controls already hidden)
dismissPlayer()
}
}
private func hideControls() {
stopControlsTimer()
withAnimation(.easeOut(duration: 0.25)) {
controlsVisible = false
focusedControl = .background
}
}
private func dismissPlayer() {
// Save progress and stop player before dismissing (matches iOS/macOS pattern)
// This ensures watch history is updated when user exits player with Menu button
playerService?.stop()
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
dismiss()
}
// MARK: - Autoplay Countdown
private func handleVideoEnded() {
// Hide controls immediately
stopControlsTimer()
withAnimation(.easeOut(duration: 0.25)) {
controlsVisible = false
}
// Check if autoplay is enabled and there's a next video
let autoPlayEnabled = appEnvironment?.settingsManager.queueAutoPlayNext ?? true
let hasNextVideo = playerState?.hasNext ?? false
if autoPlayEnabled && hasNextVideo {
startAutoplayCountdown()
} else {
// No next video or autoplay disabled - show controls with replay option
showControls()
}
}
private func startAutoplayCountdown() {
stopAutoplayCountdown()
// Get countdown duration from settings (default: 5 seconds, range: 1-15)
let countdownDuration = appEnvironment?.settingsManager.queueAutoPlayCountdown ?? 5
autoplayCountdown = countdownDuration
withAnimation(.easeIn(duration: 0.3)) {
showAutoplayCountdown = true
}
// Start countdown timer
autoplayTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
Task { @MainActor in
if self.autoplayCountdown > 1 {
self.autoplayCountdown -= 1
} else {
// Countdown finished - play next video
self.stopAutoplayCountdown()
self.playNextInQueue()
}
}
}
}
private func stopAutoplayCountdown() {
autoplayTimer?.invalidate()
autoplayTimer = nil
withAnimation(.easeOut(duration: 0.2)) {
showAutoplayCountdown = false
}
}
private func playNextInQueue() {
stopAutoplayCountdown()
Task {
await playerService?.playNext()
}
}
private func cancelAutoplay() {
stopAutoplayCountdown()
// Show controls so user can replay or manually navigate
showControls()
}
}
// MARK: - Background Button Style
/// Invisible button style for the background - no visual feedback, just captures input.
struct TVBackgroundButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
}
}
#endif

View File

@@ -0,0 +1,129 @@
//
// TVSeekPreviewView.swift
// Yattee
//
// Storyboard preview thumbnail for tvOS seek bar during scrubbing.
//
#if os(tvOS)
import SwiftUI
/// Preview thumbnail displayed during scrubbing on tvOS.
/// Scaled up for TV viewing distance.
struct TVSeekPreviewView: View {
let storyboard: Storyboard
let seekTime: TimeInterval
let chapters: [VideoChapter]
@State private var thumbnail: UIImage?
@State private var loadTask: Task<Void, Never>?
/// The current chapter based on seek time.
private var currentChapter: VideoChapter? {
chapters.last { $0.startTime <= seekTime }
}
private var formattedTime: String {
let totalSeconds = Int(seekTime)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
private let thumbnailWidth: CGFloat = 320
var body: some View {
VStack(spacing: 12) {
// Chapter name (only shown if chapters exist, larger for TV)
// Constrained to thumbnail width to prevent expanding the preview
if let chapter = currentChapter {
Text(chapter.title)
.font(.system(size: 28, weight: .medium))
.lineLimit(2)
.multilineTextAlignment(.center)
.truncationMode(.tail)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.8), radius: 3, x: 0, y: 1)
.frame(maxWidth: thumbnailWidth)
.fixedSize(horizontal: false, vertical: true)
}
// Thumbnail with timestamp overlay (scaled up for TV)
ZStack(alignment: .bottom) {
Group {
if let thumbnail {
Image(uiImage: thumbnail)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
// Placeholder while loading
Rectangle()
.fill(Color.gray.opacity(0.3))
}
}
// Timestamp overlaid at bottom center (larger for TV)
Text(formattedTime)
.font(.system(size: 36, weight: .medium))
.monospacedDigit()
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.black.opacity(0.7))
.clipShape(.rect(cornerRadius: 6))
.padding(.bottom, 8)
}
.frame(width: thumbnailWidth, height: 180)
.clipShape(.rect(cornerRadius: 8))
}
.padding(16)
.background(.ultraThinMaterial)
.clipShape(.rect(cornerRadius: 16))
.onChange(of: seekTime) { _, newTime in
loadThumbnail(for: newTime)
}
.onAppear {
loadThumbnail(for: seekTime)
}
.onDisappear {
loadTask?.cancel()
}
}
private func loadThumbnail(for time: TimeInterval) {
loadTask?.cancel()
loadTask = Task {
let service = StoryboardService.shared
// First try to get cached thumbnail
if let cached = await service.thumbnail(for: time, from: storyboard) {
await MainActor.run {
self.thumbnail = cached
}
return
}
// Load the sheet and nearby sheets
await service.preloadNearbySheets(around: time, from: storyboard)
// Check for cancellation
guard !Task.isCancelled else { return }
// Try again after loading
if let loaded = await service.thumbnail(for: time, from: storyboard) {
await MainActor.run {
self.thumbnail = loaded
}
}
}
}
}
#endif