mirror of
https://github.com/yattee/yattee.git
synced 2026-04-09 17:16:57 +00:00
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:
@@ -20,20 +20,31 @@ struct GestureSeekPreviewView: View {
|
|||||||
let theme: ControlsTheme
|
let theme: ControlsTheme
|
||||||
let chapters: [VideoChapter]
|
let chapters: [VideoChapter]
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
|
var availableWidth: CGFloat = 320
|
||||||
|
|
||||||
@State private var opacity: Double = 0
|
@State private var opacity: Double = 0
|
||||||
|
|
||||||
|
private var currentChapter: VideoChapter? {
|
||||||
|
chapters.last { $0.startTime <= seekTime }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
VStack(spacing: 6) {
|
||||||
// Only show if storyboard is available
|
if let chapter = currentChapter {
|
||||||
|
ChapterCapsuleView(
|
||||||
|
title: chapter.title,
|
||||||
|
buttonBackground: buttonBackground
|
||||||
|
)
|
||||||
|
.frame(maxWidth: availableWidth - 16)
|
||||||
|
}
|
||||||
|
|
||||||
if let storyboard {
|
if let storyboard {
|
||||||
SeekPreviewView(
|
SeekPreviewView(
|
||||||
storyboard: storyboard,
|
storyboard: storyboard,
|
||||||
seekTime: seekTime,
|
seekTime: seekTime,
|
||||||
storyboardService: storyboardService,
|
storyboardService: storyboardService,
|
||||||
buttonBackground: buttonBackground,
|
buttonBackground: buttonBackground,
|
||||||
theme: theme,
|
theme: theme
|
||||||
chapters: chapters
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -946,12 +946,24 @@ struct PlayerControlsView: View {
|
|||||||
seekTime: seekTime,
|
seekTime: seekTime,
|
||||||
storyboardService: StoryboardService.shared,
|
storyboardService: StoryboardService.shared,
|
||||||
buttonBackground: activeLayout.globalSettings.buttonBackground,
|
buttonBackground: activeLayout.globalSettings.buttonBackground,
|
||||||
theme: activeLayout.globalSettings.theme,
|
theme: activeLayout.globalSettings.theme
|
||||||
chapters: playerState.chapters
|
|
||||||
)
|
)
|
||||||
.position(x: xPosition, y: yPosition)
|
.position(x: xPosition, y: yPosition)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||||
.animation(.easeInOut(duration: 0.15), value: isDragging)
|
.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(
|
SeekTimePreviewView(
|
||||||
seekTime: seekTime,
|
seekTime: seekTime,
|
||||||
buttonBackground: activeLayout.globalSettings.buttonBackground,
|
buttonBackground: activeLayout.globalSettings.buttonBackground,
|
||||||
theme: activeLayout.globalSettings.theme,
|
theme: activeLayout.globalSettings.theme
|
||||||
chapters: playerState.chapters
|
|
||||||
)
|
)
|
||||||
.position(x: xPosition, y: yPosition)
|
.position(x: xPosition, y: yPosition)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||||
.animation(.easeInOut(duration: 0.15), value: isDragging)
|
.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 {
|
private var displayProgress: Double {
|
||||||
@@ -1087,22 +1114,25 @@ struct PlayerControlsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Seek gesture preview (top-aligned)
|
// Seek gesture preview (top-aligned)
|
||||||
VStack {
|
GeometryReader { gestureGeometry in
|
||||||
GestureSeekPreviewView(
|
VStack {
|
||||||
storyboard: playerState.preferredStoryboard,
|
GestureSeekPreviewView(
|
||||||
currentTime: seekGestureStartTime,
|
storyboard: playerState.preferredStoryboard,
|
||||||
seekTime: seekGesturePreviewTime,
|
currentTime: seekGestureStartTime,
|
||||||
duration: playerState.duration,
|
seekTime: seekGesturePreviewTime,
|
||||||
storyboardService: StoryboardService.shared,
|
duration: playerState.duration,
|
||||||
buttonBackground: activeLayout.globalSettings.buttonBackground,
|
storyboardService: StoryboardService.shared,
|
||||||
theme: activeLayout.globalSettings.theme,
|
buttonBackground: activeLayout.globalSettings.buttonBackground,
|
||||||
chapters: playerState.chapters,
|
theme: activeLayout.globalSettings.theme,
|
||||||
isActive: isSeekGestureActive
|
chapters: playerState.chapters,
|
||||||
)
|
isActive: isSeekGestureActive,
|
||||||
.padding(.top, 16)
|
availableWidth: gestureGeometry.size.width
|
||||||
Spacer()
|
)
|
||||||
|
.padding(.top, 16)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.safeAreaPadding(.top)
|
.safeAreaPadding(.top)
|
||||||
}
|
}
|
||||||
// Allow taps to pass through feedback visuals to gesture recognizer below
|
// Allow taps to pass through feedback visuals to gesture recognizer below
|
||||||
|
|||||||
@@ -7,23 +7,54 @@
|
|||||||
|
|
||||||
import SwiftUI
|
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.
|
/// Preview thumbnail displayed above the seek bar during scrubbing/hovering.
|
||||||
|
|
||||||
struct SeekPreviewView: View {
|
struct SeekPreviewView: View {
|
||||||
let storyboard: Storyboard
|
let storyboard: Storyboard
|
||||||
let seekTime: TimeInterval
|
let seekTime: TimeInterval
|
||||||
let storyboardService: StoryboardService
|
let storyboardService: StoryboardService
|
||||||
let buttonBackground: ButtonBackgroundStyle
|
let buttonBackground: ButtonBackgroundStyle
|
||||||
let theme: ControlsTheme
|
let theme: ControlsTheme
|
||||||
let chapters: [VideoChapter]
|
|
||||||
|
|
||||||
@State private var thumbnail: PlatformImage?
|
@State private var thumbnail: PlatformImage?
|
||||||
@State private var loadTask: Task<Void, Never>?
|
@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 {
|
private var formattedTime: String {
|
||||||
let totalSeconds = Int(seekTime)
|
let totalSeconds = Int(seekTime)
|
||||||
let hours = totalSeconds / 3600
|
let hours = totalSeconds / 3600
|
||||||
@@ -40,21 +71,8 @@ struct SeekPreviewView: View {
|
|||||||
private let thumbnailWidth: CGFloat = 160
|
private let thumbnailWidth: CGFloat = 160
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
// Thumbnail with timestamp overlay
|
||||||
VStack(spacing: 4) {
|
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
|
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
Group {
|
Group {
|
||||||
if let thumbnail {
|
if let thumbnail {
|
||||||
@@ -92,7 +110,6 @@ struct SeekPreviewView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
.padding(.top, currentChapter != nil ? 2 : 0)
|
|
||||||
.glassBackground(
|
.glassBackground(
|
||||||
buttonBackground.glassStyle ?? .regular,
|
buttonBackground.glassStyle ?? .regular,
|
||||||
in: .rect(cornerRadius: 8),
|
in: .rect(cornerRadius: 8),
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ struct SeekTimePreviewView: View {
|
|||||||
let seekTime: TimeInterval
|
let seekTime: TimeInterval
|
||||||
let buttonBackground: ButtonBackgroundStyle
|
let buttonBackground: ButtonBackgroundStyle
|
||||||
let theme: ControlsTheme
|
let theme: ControlsTheme
|
||||||
let chapters: [VideoChapter]
|
|
||||||
|
|
||||||
private var currentChapter: VideoChapter? {
|
|
||||||
chapters.last { $0.startTime <= seekTime }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var formattedTime: String {
|
private var formattedTime: String {
|
||||||
let totalSeconds = Int(seekTime)
|
let totalSeconds = Int(seekTime)
|
||||||
@@ -32,30 +27,18 @@ struct SeekTimePreviewView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 4) {
|
Text(formattedTime)
|
||||||
if let chapter = currentChapter {
|
.font(.system(size: 16, weight: .medium))
|
||||||
Text(chapter.title)
|
.monospacedDigit()
|
||||||
.font(.caption)
|
.foregroundStyle(.white)
|
||||||
.fontWeight(.medium)
|
.padding(.vertical, 8)
|
||||||
.lineLimit(1)
|
.padding(.horizontal, 12)
|
||||||
.truncationMode(.tail)
|
.glassBackground(
|
||||||
.foregroundStyle(.white)
|
buttonBackground.glassStyle ?? .regular,
|
||||||
.shadow(color: .black.opacity(0.8), radius: 2, x: 0, y: 1)
|
in: .rect(cornerRadius: 8),
|
||||||
}
|
fallback: .ultraThinMaterial,
|
||||||
|
colorScheme: .dark
|
||||||
Text(formattedTime)
|
)
|
||||||
.font(.system(size: 16, weight: .medium))
|
.shadow(radius: 4)
|
||||||
.monospacedDigit()
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.glassBackground(
|
|
||||||
buttonBackground.glassStyle ?? .regular,
|
|
||||||
in: .rect(cornerRadius: 8),
|
|
||||||
fallback: .ultraThinMaterial,
|
|
||||||
colorScheme: .dark
|
|
||||||
)
|
|
||||||
.shadow(radius: 4)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,8 +111,16 @@ struct MacOSControlBar: View {
|
|||||||
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
|
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
|
||||||
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
|
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
|
||||||
|
|
||||||
|
let storyboardCenterX = clampedX + previewWidth / 2
|
||||||
|
|
||||||
seekPreviewView(storyboard: storyboard)
|
seekPreviewView(storyboard: storyboard)
|
||||||
.offset(x: clampedX, y: -150)
|
.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),
|
} else if (isDragging || isHoveringProgress),
|
||||||
!playerState.isLive {
|
!playerState.isLive {
|
||||||
@@ -127,15 +135,22 @@ struct MacOSControlBar: View {
|
|||||||
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
|
let xOffset = progressBarOffset + (progressBarWidth * previewProgress) - (previewWidth / 2)
|
||||||
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
|
let clampedX = max(0, min(geometry.size.width - previewWidth, xOffset))
|
||||||
|
|
||||||
|
let timeCenterX = clampedX + previewWidth / 2
|
||||||
|
|
||||||
SeekTimePreviewView(
|
SeekTimePreviewView(
|
||||||
seekTime: previewProgress * playerState.duration,
|
seekTime: previewProgress * playerState.duration,
|
||||||
buttonBackground: .none,
|
buttonBackground: .none,
|
||||||
theme: .dark,
|
theme: .dark
|
||||||
chapters: showChapters ? playerState.chapters : []
|
|
||||||
)
|
)
|
||||||
.offset(x: clampedX, y: -60)
|
.offset(x: clampedX, y: -60)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||||
.animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress)
|
.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,
|
seekTime: seekTime,
|
||||||
storyboardService: StoryboardService.shared,
|
storyboardService: StoryboardService.shared,
|
||||||
buttonBackground: .none,
|
buttonBackground: .none,
|
||||||
theme: .dark,
|
theme: .dark
|
||||||
chapters: showChapters ? playerState.chapters : []
|
|
||||||
)
|
)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||||
.animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress)
|
.animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress)
|
||||||
|
|||||||
Reference in New Issue
Block a user