Files
yattee/Yattee/Views/Player/SeekPreviewView.swift
Arkadiusz Fal e50817c043 Add separate glass capsule for chapter title above seek preview
Extract chapter title from inside the storyboard preview into a
standalone ChapterCapsuleView with its own glass capsule background.
The capsule follows the seek position horizontally but independently
clamps to screen edges using alignmentGuide, allowing it to be wider
than the storyboard thumbnail without going offscreen.
2026-03-28 14:00:48 +01:00

158 lines
5.0 KiB
Swift

//
// SeekPreviewView.swift
// Yattee
//
// Preview thumbnail shown above the seek bar during scrubbing.
//
import SwiftUI
/// Glass capsule showing the current chapter title during seeking.
struct ChapterCapsuleView: View {
let title: String
let buttonBackground: ButtonBackgroundStyle
var body: some View {
Text(title)
.font(.caption)
.fontWeight(.medium)
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.glassBackground(
buttonBackground.glassStyle ?? .regular,
in: .capsule,
fallback: .ultraThinMaterial,
colorScheme: .dark
)
.shadow(radius: 4)
}
/// Returns this capsule positioned so its center follows `xTarget`,
/// clamped to stay within `margin` of each edge of `availableWidth`.
func positioned(xTarget: CGFloat, availableWidth: CGFloat, margin: CGFloat = 8) -> some View {
self
.alignmentGuide(.leading) { d in
let targetLeading = xTarget - d.width / 2
let clampedLeading = max(margin, min(availableWidth - d.width - margin, targetLeading))
return -clampedLeading
}
.frame(width: availableWidth, alignment: .leading)
}
}
/// Preview thumbnail displayed above the seek bar during scrubbing/hovering.
struct SeekPreviewView: View {
let storyboard: Storyboard
let seekTime: TimeInterval
let storyboardService: StoryboardService
let buttonBackground: ButtonBackgroundStyle
let theme: ControlsTheme
@State private var thumbnail: PlatformImage?
@State private var loadTask: Task<Void, Never>?
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 = 160
var body: some View {
// Thumbnail with timestamp overlay
VStack(spacing: 4) {
ZStack(alignment: .bottom) {
Group {
if let thumbnail {
#if os(macOS)
Image(nsImage: thumbnail)
.resizable()
.aspectRatio(contentMode: .fill)
#else
Image(uiImage: thumbnail)
.resizable()
.aspectRatio(contentMode: .fill)
#endif
} else {
// Placeholder while loading
Rectangle()
.fill(Color.gray.opacity(0.3))
}
}
.frame(width: thumbnailWidth, height: 90)
.clipped()
// Timestamp overlaid at bottom center
Text(formattedTime)
.font(.caption)
.fontWeight(.medium)
.monospacedDigit()
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.black.opacity(0.7))
.clipShape(.rect(cornerRadius: 4))
.padding(.bottom, 4)
}
.clipShape(.rect(cornerRadius: 4))
}
.padding(.vertical, 6)
.padding(.horizontal, 4)
.glassBackground(
buttonBackground.glassStyle ?? .regular,
in: .rect(cornerRadius: 8),
fallback: .ultraThinMaterial,
colorScheme: .dark
)
.shadow(radius: 4)
.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 {
// First try to get cached thumbnail
if let cached = await storyboardService.thumbnail(for: time, from: storyboard) {
await MainActor.run {
self.thumbnail = cached
}
return
}
// Load the sheet and nearby sheets
await storyboardService.preloadNearbySheets(around: time, from: storyboard)
// Check for cancellation
guard !Task.isCancelled else { return }
// Try again after loading
if let loaded = await storyboardService.thumbnail(for: time, from: storyboard) {
await MainActor.run {
self.thumbnail = loaded
}
}
}
}
}