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.
This commit is contained in:
Arkadiusz Fal
2026-03-28 14:00:48 +01:00
parent 3d1974930b
commit e50817c043
5 changed files with 133 additions and 78 deletions

View File

@@ -20,20 +20,31 @@ struct GestureSeekPreviewView: View {
let theme: ControlsTheme
let chapters: [VideoChapter]
let isActive: Bool
var availableWidth: CGFloat = 320
@State private var opacity: Double = 0
private var currentChapter: VideoChapter? {
chapters.last { $0.startTime <= seekTime }
}
var body: some View {
Group {
// Only show if storyboard is available
VStack(spacing: 6) {
if let chapter = currentChapter {
ChapterCapsuleView(
title: chapter.title,
buttonBackground: buttonBackground
)
.frame(maxWidth: availableWidth - 16)
}
if let storyboard {
SeekPreviewView(
storyboard: storyboard,
seekTime: seekTime,
storyboardService: storyboardService,
buttonBackground: buttonBackground,
theme: theme,
chapters: chapters
theme: theme
)
}
}

View File

@@ -946,12 +946,24 @@ struct PlayerControlsView: View {
seekTime: seekTime,
storyboardService: StoryboardService.shared,
buttonBackground: activeLayout.globalSettings.buttonBackground,
theme: activeLayout.globalSettings.theme,
chapters: playerState.chapters
theme: activeLayout.globalSettings.theme
)
.position(x: xPosition, y: yPosition)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging)
// Chapter capsule follows storyboard x but clamps to screen edges
if let chapter = chapterForSeekTime(seekTime) {
let capsuleY = yPosition - previewHeight / 2 - 6 - 12
ChapterCapsuleView(
title: chapter.title,
buttonBackground: activeLayout.globalSettings.buttonBackground
)
.positioned(xTarget: xPosition, availableWidth: geometry.size.width)
.position(x: geometry.size.width / 2, y: capsuleY)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging)
}
}
}
@@ -966,12 +978,27 @@ struct PlayerControlsView: View {
SeekTimePreviewView(
seekTime: seekTime,
buttonBackground: activeLayout.globalSettings.buttonBackground,
theme: activeLayout.globalSettings.theme,
chapters: playerState.chapters
theme: activeLayout.globalSettings.theme
)
.position(x: xPosition, y: yPosition)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging)
if let chapter = chapterForSeekTime(seekTime) {
let capsuleY = yPosition - 24
ChapterCapsuleView(
title: chapter.title,
buttonBackground: activeLayout.globalSettings.buttonBackground
)
.positioned(xTarget: xPosition, availableWidth: geometry.size.width)
.position(x: geometry.size.width / 2, y: capsuleY)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging)
}
}
private func chapterForSeekTime(_ seekTime: TimeInterval) -> VideoChapter? {
playerState.chapters.last { $0.startTime <= seekTime }
}
private var displayProgress: Double {
@@ -1087,6 +1114,7 @@ struct PlayerControlsView: View {
}
// Seek gesture preview (top-aligned)
GeometryReader { gestureGeometry in
VStack {
GestureSeekPreviewView(
storyboard: playerState.preferredStoryboard,
@@ -1097,12 +1125,14 @@ struct PlayerControlsView: View {
buttonBackground: activeLayout.globalSettings.buttonBackground,
theme: activeLayout.globalSettings.theme,
chapters: playerState.chapters,
isActive: isSeekGestureActive
isActive: isSeekGestureActive,
availableWidth: gestureGeometry.size.width
)
.padding(.top, 16)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.safeAreaPadding(.top)
}
// Allow taps to pass through feedback visuals to gesture recognizer below

View File

@@ -7,23 +7,54 @@
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
let chapters: [VideoChapter]
@State private var thumbnail: PlatformImage?
@State private var loadTask: Task<Void, Never>?
/// The current chapter based on seek time.
private var currentChapter: VideoChapter? {
chapters.last { $0.startTime <= seekTime }
}
private var formattedTime: String {
let totalSeconds = Int(seekTime)
let hours = totalSeconds / 3600
@@ -40,21 +71,8 @@ struct SeekPreviewView: View {
private let thumbnailWidth: CGFloat = 160
var body: some View {
VStack(spacing: 4) {
// Chapter name (only shown if chapters exist)
// Constrained to thumbnail width to prevent expanding the preview
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)
.frame(maxWidth: thumbnailWidth)
}
// Thumbnail with timestamp overlay
VStack(spacing: 4) {
ZStack(alignment: .bottom) {
Group {
if let thumbnail {
@@ -92,7 +110,6 @@ struct SeekPreviewView: View {
}
.padding(.vertical, 6)
.padding(.horizontal, 4)
.padding(.top, currentChapter != nil ? 2 : 0)
.glassBackground(
buttonBackground.glassStyle ?? .regular,
in: .rect(cornerRadius: 8),

View File

@@ -12,11 +12,6 @@ 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)
@@ -32,22 +27,10 @@ struct SeekTimePreviewView: View {
}
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(

View File

@@ -111,8 +111,16 @@ struct MacOSControlBar: View {
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
let storyboardCenterX = clampedX + previewWidth / 2
seekPreviewView(storyboard: storyboard)
.offset(x: clampedX, y: -150)
if showChapters, let chapter = playerState.chapters.last(where: { $0.startTime <= previewProgress * playerState.duration }) {
ChapterCapsuleView(title: chapter.title, buttonBackground: .none)
.positioned(xTarget: storyboardCenterX, availableWidth: geometry.size.width)
.offset(y: -176)
}
}
} else if (isDragging || isHoveringProgress),
!playerState.isLive {
@@ -127,15 +135,22 @@ struct MacOSControlBar: View {
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
let timeCenterX = clampedX + previewWidth / 2
SeekTimePreviewView(
seekTime: previewProgress * playerState.duration,
buttonBackground: .none,
theme: .dark,
chapters: showChapters ? playerState.chapters : []
theme: .dark
)
.offset(x: clampedX, y: -60)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress)
if showChapters, let chapter = playerState.chapters.last(where: { $0.startTime <= previewProgress * playerState.duration }) {
ChapterCapsuleView(title: chapter.title, buttonBackground: .none)
.positioned(xTarget: timeCenterX, availableWidth: geometry.size.width)
.offset(y: -86)
}
}
}
}
@@ -331,8 +346,7 @@ struct MacOSControlBar: View {
seekTime: seekTime,
storyboardService: StoryboardService.shared,
buttonBackground: .none,
theme: .dark,
chapters: showChapters ? playerState.chapters : []
theme: .dark
)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress)