Show seek time preview when no storyboards available

Display a floating time pill above the seek bar during dragging
(iOS) and dragging/hovering (macOS) when video has no storyboard
thumbnails. Includes chapter name when available.
This commit is contained in:
Arkadiusz Fal
2026-03-28 13:18:02 +01:00
parent 3c04b8540f
commit f7fdc314f6
3 changed files with 105 additions and 0 deletions

View File

@@ -883,6 +883,8 @@ struct PlayerControlsView: View {
.overlay {
if !playerState.isLive, let storyboard = playerState.preferredStoryboard {
seekPreviewOverlay(storyboard: storyboard, geometry: geometry)
} else if !playerState.isLive, isDragging {
seekTimePreviewOverlay(geometry: geometry)
}
}
.gesture(
@@ -953,6 +955,25 @@ struct PlayerControlsView: View {
}
}
@ViewBuilder
private func seekTimePreviewOverlay(geometry: GeometryProxy) -> some View {
let seekTime = dragProgress * playerState.duration
let previewWidth: CGFloat = 80
let halfWidth = previewWidth / 2
let xPosition = max(halfWidth, min(geometry.size.width - halfWidth, geometry.size.width * dragProgress))
let yPosition: CGFloat = -20
SeekTimePreviewView(
seekTime: seekTime,
buttonBackground: activeLayout.globalSettings.buttonBackground,
theme: activeLayout.globalSettings.theme,
chapters: playerState.chapters
)
.position(x: xPosition, y: yPosition)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging)
}
private var displayProgress: Double {
(isDragging || isPendingSeek) ? dragProgress : playerState.progress
}

View File

@@ -0,0 +1,61 @@
//
// SeekTimePreviewView.swift
// Yattee
//
// Seek time preview shown when no storyboard thumbnails are available.
//
import SwiftUI
/// Lightweight seek time pill displayed above the seek bar when no storyboard is available.
struct SeekTimePreviewView: View {
let seekTime: TimeInterval
let buttonBackground: ButtonBackgroundStyle
let theme: ControlsTheme
let chapters: [VideoChapter]
private var currentChapter: VideoChapter? {
chapters.last { $0.startTime <= seekTime }
}
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)
}
}
var body: some View {
VStack(spacing: 4) {
if let chapter = currentChapter {
Text(chapter.title)
.font(.caption)
.fontWeight(.medium)
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.8), radius: 2, x: 0, y: 1)
}
Text(formattedTime)
.font(.system(size: 16, weight: .medium))
.monospacedDigit()
.foregroundStyle(.white)
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.glassBackground(
buttonBackground.glassStyle ?? .regular,
in: .rect(cornerRadius: 8),
fallback: .ultraThinMaterial,
colorScheme: .dark
)
.shadow(radius: 4)
}
}

View File

@@ -114,6 +114,29 @@ struct MacOSControlBar: View {
seekPreviewView(storyboard: storyboard)
.offset(x: clampedX, y: -150)
}
} else if (isDragging || isHoveringProgress),
!playerState.isLive {
GeometryReader { geometry in
let previewProgress = isDragging ? dragProgress : hoverProgress
let horizontalPadding: CGFloat = 16
let timeLabelWidth: CGFloat = 50
let spacing: CGFloat = 8
let progressBarOffset: CGFloat = horizontalPadding + timeLabelWidth + spacing
let progressBarWidth: CGFloat = geometry.size.width - (2 * horizontalPadding) - (2 * timeLabelWidth) - (2 * spacing)
let previewWidth: CGFloat = 80
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
SeekTimePreviewView(
seekTime: previewProgress * playerState.duration,
buttonBackground: .none,
theme: .dark,
chapters: showChapters ? playerState.chapters : []
)
.offset(x: clampedX, y: -60)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress)
}
}
}
}