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)