From 43f62d997fb2fb7a37d497578d13ec9fc007bb90 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 15 Apr 2026 05:05:55 +0200 Subject: [PATCH] Match tvOS seek preview to iOS glass design --- .../Player/tvOS/TVPlayerProgressBar.swift | 23 ++-- .../Views/Player/tvOS/TVSeekPreviewView.swift | 102 +++++++++--------- 2 files changed, 71 insertions(+), 54 deletions(-) diff --git a/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift b/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift index 64efcb4e..b52e0e3d 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift @@ -213,21 +213,31 @@ struct TVPlayerProgressBar: View { @ViewBuilder private func scrubPreviewOverlay(geometry: GeometryProxy) -> some View { if isScrubbing { - let previewWidth: CGFloat = 352 - let previewHeight: CGFloat = 260 + let seekTime = scrubTime ?? currentTime + let currentChapter = showChapters ? chapters.last(where: { $0.startTime <= seekTime }) : nil + // Panel is 320 thumbnail + 4pt horizontal padding * 2 = 328, plus shadow. + // Use a slightly larger value for x-clamping to keep shadow inside screen. + let previewWidth: CGFloat = 344 + // Panel height (thumbnail 180 + 6pt vertical padding * 2 = 192) plus + // optional chapter capsule (~36pt) and 8pt VStack spacing. + let previewHeight: CGFloat = currentChapter != nil ? 244 : 200 let halfWidth = previewWidth / 2 let clampedX = max(halfWidth, min(geometry.size.width - halfWidth, geometry.size.width * progress)) let yPosition = -previewHeight / 2 - 16 - Group { + VStack(spacing: 8) { + if let currentChapter { + TVChapterCapsuleView(title: currentChapter.title) + .frame(maxWidth: 320) + } + if let storyboard { TVSeekPreviewView( storyboard: storyboard, - seekTime: scrubTime ?? currentTime, - chapters: showChapters ? chapters : [] + seekTime: seekTime ) } else { - Text((scrubTime ?? currentTime).formattedAsTimestamp) + Text(seekTime.formattedAsTimestamp) .font(.system(size: 48, weight: .medium)) .monospacedDigit() .foregroundStyle(.white) @@ -239,6 +249,7 @@ struct TVPlayerProgressBar: View { ) } } + .fixedSize() .position(x: clampedX, y: yPosition) .transition(.scale.combined(with: .opacity)) .allowsHitTesting(false) diff --git a/Yattee/Views/Player/tvOS/TVSeekPreviewView.swift b/Yattee/Views/Player/tvOS/TVSeekPreviewView.swift index 08aa8e2e..759e0091 100644 --- a/Yattee/Views/Player/tvOS/TVSeekPreviewView.swift +++ b/Yattee/Views/Player/tvOS/TVSeekPreviewView.swift @@ -8,70 +8,76 @@ #if os(tvOS) import SwiftUI +/// Glass capsule showing the current chapter title above the tvOS seek preview. +struct TVChapterCapsuleView: View { + let title: String + + var body: some View { + Text(title) + .font(.system(size: 24, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .glassBackground( + .regular, + in: .capsule, + fallback: .ultraThinMaterial, + colorScheme: .dark + ) + .shadow(radius: 4) + } +} + /// 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? - /// The current chapter based on seek time. - private var currentChapter: VideoChapter? { - chapters.last { $0.startTime <= seekTime } - } - 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)) - } + // Thumbnail with timestamp overlay (scaled up for TV) + ZStack(alignment: .bottom) { + Group { + if let thumbnail { + Image(uiImage: thumbnail) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + // Placeholder while loading + Rectangle() + .fill(Color.gray.opacity(0.3)) } - - // Timestamp overlaid at bottom center (larger for TV) - Text(seekTime.formattedAsTimestamp) - .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)) + .clipped() + + // Timestamp overlaid at bottom center (larger for TV) + Text(seekTime.formattedAsTimestamp) + .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) } - .padding(16) - .background(.ultraThinMaterial) - .clipShape(.rect(cornerRadius: 16)) + .clipShape(.rect(cornerRadius: 4)) + .padding(4) + .glassBackground( + .regular, + in: .rect(cornerRadius: 8), + fallback: .ultraThinMaterial, + colorScheme: .dark + ) + .shadow(radius: 4) .onChange(of: seekTime) { _, newTime in loadThumbnail(for: newTime) }