From e50817c043d4f39271d8729c06438451b755e03b Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 28 Mar 2026 14:00:48 +0100 Subject: [PATCH] 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. --- .../Gestures/GestureSeekPreviewView.swift | 19 ++++-- Yattee/Views/Player/PlayerControlsView.swift | 68 +++++++++++++------ Yattee/Views/Player/SeekPreviewView.swift | 59 ++++++++++------ Yattee/Views/Player/SeekTimePreviewView.swift | 43 ++++-------- .../Views/Player/macOS/MacOSControlBar.swift | 22 ++++-- 5 files changed, 133 insertions(+), 78 deletions(-) diff --git a/Yattee/Views/Player/Gestures/GestureSeekPreviewView.swift b/Yattee/Views/Player/Gestures/GestureSeekPreviewView.swift index 92cd1e51..58b76430 100644 --- a/Yattee/Views/Player/Gestures/GestureSeekPreviewView.swift +++ b/Yattee/Views/Player/Gestures/GestureSeekPreviewView.swift @@ -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 ) } } diff --git a/Yattee/Views/Player/PlayerControlsView.swift b/Yattee/Views/Player/PlayerControlsView.swift index 9c7b5c94..316e4e22 100644 --- a/Yattee/Views/Player/PlayerControlsView.swift +++ b/Yattee/Views/Player/PlayerControlsView.swift @@ -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,22 +1114,25 @@ struct PlayerControlsView: View { } // Seek gesture preview (top-aligned) - VStack { - GestureSeekPreviewView( - storyboard: playerState.preferredStoryboard, - currentTime: seekGestureStartTime, - seekTime: seekGesturePreviewTime, - duration: playerState.duration, - storyboardService: StoryboardService.shared, - buttonBackground: activeLayout.globalSettings.buttonBackground, - theme: activeLayout.globalSettings.theme, - chapters: playerState.chapters, - isActive: isSeekGestureActive - ) - .padding(.top, 16) - Spacer() + GeometryReader { gestureGeometry in + VStack { + GestureSeekPreviewView( + storyboard: playerState.preferredStoryboard, + currentTime: seekGestureStartTime, + seekTime: seekGesturePreviewTime, + duration: playerState.duration, + storyboardService: StoryboardService.shared, + buttonBackground: activeLayout.globalSettings.buttonBackground, + theme: activeLayout.globalSettings.theme, + chapters: playerState.chapters, + isActive: isSeekGestureActive, + availableWidth: gestureGeometry.size.width + ) + .padding(.top, 16) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) .safeAreaPadding(.top) } // Allow taps to pass through feedback visuals to gesture recognizer below diff --git a/Yattee/Views/Player/SeekPreviewView.swift b/Yattee/Views/Player/SeekPreviewView.swift index 86a09d42..e4539cb6 100644 --- a/Yattee/Views/Player/SeekPreviewView.swift +++ b/Yattee/Views/Player/SeekPreviewView.swift @@ -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? - /// 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 { + // Thumbnail with timestamp overlay 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) { 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), diff --git a/Yattee/Views/Player/SeekTimePreviewView.swift b/Yattee/Views/Player/SeekTimePreviewView.swift index 41274ce4..88d73a4f 100644 --- a/Yattee/Views/Player/SeekTimePreviewView.swift +++ b/Yattee/Views/Player/SeekTimePreviewView.swift @@ -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,30 +27,18 @@ 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( - buttonBackground.glassStyle ?? .regular, - in: .rect(cornerRadius: 8), - fallback: .ultraThinMaterial, - colorScheme: .dark - ) - .shadow(radius: 4) + 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) } } diff --git a/Yattee/Views/Player/macOS/MacOSControlBar.swift b/Yattee/Views/Player/macOS/MacOSControlBar.swift index ccd83bf7..4e6e3b13 100644 --- a/Yattee/Views/Player/macOS/MacOSControlBar.swift +++ b/Yattee/Views/Player/macOS/MacOSControlBar.swift @@ -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)