mirror of
https://github.com/yattee/yattee.git
synced 2026-04-09 17:16:57 +00:00
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.
158 lines
5.0 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|