Files
yattee/Yattee/Views/Player/SeekPreviewView.swift
Arkadiusz Fal 44f3cbb9f3 Deduplicate time formatting and clean up unused code
Extract shared TimeInterval.formattedAsTimestamp replacing 8 identical
formatTime/formattedTime implementations across player views. Remove
unused currentTime parameter from GestureSeekPreviewView. Consolidate
duplicated geometry math in MacOSControlBar into seekPreviewPosition().
2026-03-28 14:09:25 +01:00

145 lines
4.7 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 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(seekTime.formattedAsTimestamp)
.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
}
}
}
}
}