Match tvOS seek preview to iOS glass design

This commit is contained in:
Arkadiusz Fal
2026-04-15 05:05:55 +02:00
parent 7067413b9b
commit 43f62d997f
2 changed files with 71 additions and 54 deletions

View File

@@ -213,21 +213,31 @@ struct TVPlayerProgressBar: View {
@ViewBuilder @ViewBuilder
private func scrubPreviewOverlay(geometry: GeometryProxy) -> some View { private func scrubPreviewOverlay(geometry: GeometryProxy) -> some View {
if isScrubbing { if isScrubbing {
let previewWidth: CGFloat = 352 let seekTime = scrubTime ?? currentTime
let previewHeight: CGFloat = 260 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 halfWidth = previewWidth / 2
let clampedX = max(halfWidth, min(geometry.size.width - halfWidth, geometry.size.width * progress)) let clampedX = max(halfWidth, min(geometry.size.width - halfWidth, geometry.size.width * progress))
let yPosition = -previewHeight / 2 - 16 let yPosition = -previewHeight / 2 - 16
Group { VStack(spacing: 8) {
if let currentChapter {
TVChapterCapsuleView(title: currentChapter.title)
.frame(maxWidth: 320)
}
if let storyboard { if let storyboard {
TVSeekPreviewView( TVSeekPreviewView(
storyboard: storyboard, storyboard: storyboard,
seekTime: scrubTime ?? currentTime, seekTime: seekTime
chapters: showChapters ? chapters : []
) )
} else { } else {
Text((scrubTime ?? currentTime).formattedAsTimestamp) Text(seekTime.formattedAsTimestamp)
.font(.system(size: 48, weight: .medium)) .font(.system(size: 48, weight: .medium))
.monospacedDigit() .monospacedDigit()
.foregroundStyle(.white) .foregroundStyle(.white)
@@ -239,6 +249,7 @@ struct TVPlayerProgressBar: View {
) )
} }
} }
.fixedSize()
.position(x: clampedX, y: yPosition) .position(x: clampedX, y: yPosition)
.transition(.scale.combined(with: .opacity)) .transition(.scale.combined(with: .opacity))
.allowsHitTesting(false) .allowsHitTesting(false)

View File

@@ -8,70 +8,76 @@
#if os(tvOS) #if os(tvOS)
import SwiftUI 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. /// Preview thumbnail displayed during scrubbing on tvOS.
/// Scaled up for TV viewing distance. /// Scaled up for TV viewing distance.
struct TVSeekPreviewView: View { struct TVSeekPreviewView: View {
let storyboard: Storyboard let storyboard: Storyboard
let seekTime: TimeInterval let seekTime: TimeInterval
let chapters: [VideoChapter]
@State private var thumbnail: UIImage? @State private var thumbnail: UIImage?
@State private var loadTask: Task<Void, Never>? @State private var loadTask: Task<Void, Never>?
/// The current chapter based on seek time.
private var currentChapter: VideoChapter? {
chapters.last { $0.startTime <= seekTime }
}
private let thumbnailWidth: CGFloat = 320 private let thumbnailWidth: CGFloat = 320
var body: some View { var body: some View {
VStack(spacing: 12) { // Thumbnail with timestamp overlay (scaled up for TV)
// Chapter name (only shown if chapters exist, larger for TV) ZStack(alignment: .bottom) {
// Constrained to thumbnail width to prevent expanding the preview Group {
if let chapter = currentChapter { if let thumbnail {
Text(chapter.title) Image(uiImage: thumbnail)
.font(.system(size: 28, weight: .medium)) .resizable()
.lineLimit(2) .aspectRatio(contentMode: .fill)
.multilineTextAlignment(.center) } else {
.truncationMode(.tail) // Placeholder while loading
.foregroundStyle(.white) Rectangle()
.shadow(color: .black.opacity(0.8), radius: 3, x: 0, y: 1) .fill(Color.gray.opacity(0.3))
.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(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) .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) .clipShape(.rect(cornerRadius: 4))
.background(.ultraThinMaterial) .padding(4)
.clipShape(.rect(cornerRadius: 16)) .glassBackground(
.regular,
in: .rect(cornerRadius: 8),
fallback: .ultraThinMaterial,
colorScheme: .dark
)
.shadow(radius: 4)
.onChange(of: seekTime) { _, newTime in .onChange(of: seekTime) { _, newTime in
loadThumbnail(for: newTime) loadThumbnail(for: newTime)
} }