mirror of
https://github.com/yattee/yattee.git
synced 2026-04-10 09:36:58 +00:00
Yattee v2 rewrite
This commit is contained in:
176
Yattee/Views/Player/tvOS/TVAutoplayCountdownView.swift
Normal file
176
Yattee/Views/Player/tvOS/TVAutoplayCountdownView.swift
Normal 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
|
||||
883
Yattee/Views/Player/tvOS/TVDetailsPanel.swift
Normal file
883
Yattee/Views/Player/tvOS/TVDetailsPanel.swift
Normal 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
|
||||
379
Yattee/Views/Player/tvOS/TVPlayerControlsView.swift
Normal file
379
Yattee/Views/Player/tvOS/TVPlayerControlsView.swift
Normal 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
|
||||
421
Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift
Normal file
421
Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift
Normal 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
|
||||
558
Yattee/Views/Player/tvOS/TVPlayerView.swift
Normal file
558
Yattee/Views/Player/tvOS/TVPlayerView.swift
Normal 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
|
||||
129
Yattee/Views/Player/tvOS/TVSeekPreviewView.swift
Normal file
129
Yattee/Views/Player/tvOS/TVSeekPreviewView.swift
Normal 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
|
||||
Reference in New Issue
Block a user