Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,73 @@
//
// GestureSeekPreviewView.swift
// Yattee
//
// Seek preview overlay shown during drag-to-seek gesture.
//
#if os(iOS)
import SwiftUI
/// Preview overlay shown during drag-to-seek gesture.
/// Shows only the storyboard thumbnail with timestamp overlay.
struct GestureSeekPreviewView: View {
let storyboard: Storyboard?
let currentTime: TimeInterval
let seekTime: TimeInterval
let duration: TimeInterval
let storyboardService: StoryboardService
let buttonBackground: ButtonBackgroundStyle
let theme: ControlsTheme
let chapters: [VideoChapter]
let isActive: Bool
@State private var opacity: Double = 0
var body: some View {
Group {
// Only show if storyboard is available
if let storyboard {
SeekPreviewView(
storyboard: storyboard,
seekTime: seekTime,
storyboardService: storyboardService,
buttonBackground: buttonBackground,
theme: theme,
chapters: chapters
)
}
}
.opacity(opacity)
.onChange(of: isActive) { _, active in
withAnimation(.easeInOut(duration: 0.2)) {
opacity = active ? 1 : 0
}
}
.onAppear {
if isActive {
withAnimation(.easeOut(duration: 0.15)) {
opacity = 1
}
}
}
}
}
#Preview {
ZStack {
Color.black
GestureSeekPreviewView(
storyboard: nil,
currentTime: 120,
seekTime: 180,
duration: 600,
storyboardService: StoryboardService(),
buttonBackground: .regularGlass,
theme: .dark,
chapters: [],
isActive: true
)
}
}
#endif

View File

@@ -0,0 +1,117 @@
//
// OverscrollGestureHandler.swift
// Yattee
//
// UIKit gesture handler for detecting overscroll pull-down gestures on UIScrollView.
// When user pulls down at scroll top, disables bounce and forwards drag events for smooth
// panel collapse animation.
//
#if os(iOS)
import UIKit
/// Coordinates overscroll detection on a UIScrollView, calling back during pull-down gestures
/// when the scroll is at top. Disables bounce during the gesture to allow smooth animation.
final class OverscrollGestureHandler: NSObject, UIGestureRecognizerDelegate {
// MARK: - Properties
weak var scrollView: UIScrollView?
var onDragChanged: ((CGFloat) -> Void)?
var onDragEnded: ((CGFloat, CGFloat) -> Void)?
/// Whether we're currently tracking an overscroll gesture
private var isTracking = false
/// The pan gesture recognizer we add to the scroll view
private var panRecognizer: UIPanGestureRecognizer?
// MARK: - Setup
/// Attaches the pan gesture recognizer to the scroll view.
func attach(to scrollView: UIScrollView) {
detach()
self.scrollView = scrollView
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.delegate = self
scrollView.addGestureRecognizer(pan)
panRecognizer = pan
}
/// Removes the pan gesture recognizer from the scroll view.
func detach() {
if let recognizer = panRecognizer, let view = recognizer.view {
view.removeGestureRecognizer(recognizer)
}
panRecognizer = nil
scrollView = nil
isTracking = false
}
// MARK: - Gesture Handling
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
guard let scrollView else { return }
switch gesture.state {
case .began:
// Start tracking - disable bounce so we can control the movement
isTracking = true
scrollView.bounces = false
case .changed:
let translation = gesture.translation(in: gesture.view)
// Only forward positive (pull down) translations
if translation.y > 0 {
onDragChanged?(translation.y)
}
case .ended, .cancelled:
// Re-enable bounce
scrollView.bounces = true
isTracking = false
let translation = gesture.translation(in: gesture.view)
let velocity = gesture.velocity(in: gesture.view)
// Calculate predicted end position
let decelerationTime: CGFloat = 0.3
let predicted = translation.y + velocity.y * decelerationTime
onDragEnded?(translation.y, predicted)
default:
break
}
}
// MARK: - UIGestureRecognizerDelegate
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer,
let scrollView else {
return false
}
let velocity = pan.velocity(in: pan.view)
// Only begin if:
// 1. Scroll view is at top (contentOffset.y <= 0)
// 2. User is pulling down (velocity.y > 0)
// 3. Vertical movement is dominant (to not interfere with horizontal scrolling)
let isAtTop = scrollView.contentOffset.y <= 0
let isPullingDown = velocity.y > 0
let isVerticalDominant = abs(velocity.y) > abs(velocity.x)
return isAtTop && isPullingDown && isVerticalDominant
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
// Don't allow simultaneous recognition - we take over when overscrolling
false
}
}
#endif

View File

@@ -0,0 +1,121 @@
//
// OverscrollGestureView.swift
// Yattee
//
// UIViewRepresentable that attaches an OverscrollGestureHandler to a parent UIScrollView.
// Place this as a background on a SwiftUI ScrollView to intercept pull-down overscroll gestures.
//
#if os(iOS)
import SwiftUI
import UIKit
/// A transparent view that finds its parent UIScrollView and attaches an overscroll gesture handler.
/// Use as a `.background` on a SwiftUI `ScrollView` to intercept pull-down gestures at scroll top.
struct OverscrollGestureView: UIViewRepresentable {
/// Called during the drag with the vertical translation (positive = pulling down)
var onDragChanged: ((CGFloat) -> Void)?
/// Called when the drag ends with translation and predicted end translation
var onDragEnded: ((CGFloat, CGFloat) -> Void)?
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
// Store callbacks on coordinator
context.coordinator.onDragChanged = onDragChanged
context.coordinator.onDragEnded = onDragEnded
// Schedule scroll view discovery after view is in hierarchy
DispatchQueue.main.async {
if let scrollView = Self.findScrollView(from: view) {
context.coordinator.gestureHandler.attach(to: scrollView)
}
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
// Update callbacks
context.coordinator.onDragChanged = onDragChanged
context.coordinator.onDragEnded = onDragEnded
// If not attached yet, try again
if context.coordinator.gestureHandler.scrollView == nil {
DispatchQueue.main.async {
if let scrollView = Self.findScrollView(from: uiView) {
context.coordinator.gestureHandler.attach(to: scrollView)
}
}
}
}
static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) {
coordinator.gestureHandler.detach()
}
// MARK: - Scroll View Discovery
/// Finds the UIScrollView in the view hierarchy.
/// Since .background creates a separate branch, we need to find a common ancestor
/// and search all its descendants.
private static func findScrollView(from view: UIView) -> UIScrollView? {
// Collect all ancestors
var ancestors: [UIView] = []
var current: UIView? = view.superview
while let parent = current {
ancestors.append(parent)
current = parent.superview
}
// Search from each ancestor level, starting closest
for ancestor in ancestors {
// Collect all scroll views at this level
var scrollViews: [UIScrollView] = []
collectScrollViews(in: ancestor, into: &scrollViews)
if !scrollViews.isEmpty {
// Return the first one that has meaningful content (not a tiny internal scroll view)
if let meaningful = scrollViews.first(where: { $0.frame.height > 100 }) {
return meaningful
}
return scrollViews.first
}
}
return nil
}
private static func collectScrollViews(in view: UIView, into result: inout [UIScrollView]) {
for subview in view.subviews {
if let scrollView = subview as? UIScrollView {
result.append(scrollView)
}
collectScrollViews(in: subview, into: &result)
}
}
// MARK: - Coordinator
final class Coordinator {
let gestureHandler = OverscrollGestureHandler()
var onDragChanged: ((CGFloat) -> Void)? {
didSet {
gestureHandler.onDragChanged = onDragChanged
}
}
var onDragEnded: ((CGFloat, CGFloat) -> Void)? {
didSet {
gestureHandler.onDragEnded = onDragEnded
}
}
}
}
#endif

View File

@@ -0,0 +1,226 @@
//
// PlayerGestureCoordinator.swift
// Yattee
//
// UIKit gesture recognizer coordinator for player gestures.
//
#if os(iOS)
import UIKit
/// Coordinates UIKit gesture recognizers for player tap and seek gestures.
final class PlayerGestureCoordinator: NSObject, UIGestureRecognizerDelegate {
// MARK: - Configuration
var tapSettings: TapGesturesSettings
var seekSettings: SeekGestureSettings
var bounds: CGRect = .zero
/// Whether gesture actions (double-tap, seek) should be active.
/// Single tap to toggle controls visibility always works regardless of this flag.
var isActive: Bool = true
/// Whether the content is seekable (false for live streams).
var isSeekable: Bool = true
// MARK: - Callbacks
var onDoubleTap: ((TapZonePosition) -> Void)?
var onSingleTap: (() -> Void)?
/// Returns true if a pinch gesture is currently active (blocks seek gesture).
var isPinchGestureActive: (() -> Bool)?
/// Returns true if panel drag is active (blocks seek gesture).
var isPanelDragging: (() -> Bool)?
/// Called when seek gesture is recognized (after activation threshold).
var onSeekGestureStarted: (() -> Void)?
/// Called during seek gesture with cumulative horizontal translation.
var onSeekGestureChanged: ((CGFloat) -> Void)?
/// Called when seek gesture ends with final horizontal translation.
var onSeekGestureEnded: ((CGFloat) -> Void)?
// MARK: - Gesture Recognizers
private var doubleTapRecognizer: UITapGestureRecognizer?
private var singleTapRecognizer: UITapGestureRecognizer?
private var panRecognizer: UIPanGestureRecognizer?
// MARK: - Seek Gesture State
/// Whether the current pan has been recognized as a seek gesture.
private var isRecognizedAsSeekGesture = false
/// Starting translation when seek gesture was recognized.
private var seekGestureStartTranslation: CGPoint = .zero
// MARK: - Initialization
init(tapSettings: TapGesturesSettings, seekSettings: SeekGestureSettings = .default) {
self.tapSettings = tapSettings
self.seekSettings = seekSettings
super.init()
}
// MARK: - Setup
/// Attaches gesture recognizers to the view.
func attach(to view: UIView) {
detach()
// Double-tap recognizer
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
doubleTap.numberOfTapsRequired = 2
doubleTap.delegate = self
view.addGestureRecognizer(doubleTap)
doubleTapRecognizer = doubleTap
// Single-tap recognizer (requires double-tap to fail)
let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap(_:)))
singleTap.numberOfTapsRequired = 1
singleTap.require(toFail: doubleTap)
singleTap.delegate = self
view.addGestureRecognizer(singleTap)
singleTapRecognizer = singleTap
// Pan recognizer for seek gesture
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.delegate = self
view.addGestureRecognizer(pan)
panRecognizer = pan
// Single tap should require pan to fail for better UX
// This prevents single tap from firing if user starts dragging
singleTap.require(toFail: pan)
// Update double-tap timing
updateDoubleTapTiming()
}
/// Removes gesture recognizers from the view.
func detach() {
if let recognizer = doubleTapRecognizer {
recognizer.view?.removeGestureRecognizer(recognizer)
}
if let recognizer = singleTapRecognizer {
recognizer.view?.removeGestureRecognizer(recognizer)
}
if let recognizer = panRecognizer {
recognizer.view?.removeGestureRecognizer(recognizer)
}
doubleTapRecognizer = nil
singleTapRecognizer = nil
panRecognizer = nil
}
/// Updates the double-tap timing window.
func updateDoubleTapTiming() {
// iOS doesn't have a direct API for this, but we can use
// the delay for single-tap via the require(toFail:) mechanism
// The actual timing is controlled by iOS based on the interval
}
// MARK: - Gesture Handlers
@objc private func handleDoubleTap(_ recognizer: UITapGestureRecognizer) {
guard isActive, tapSettings.isEnabled else { return }
let location = recognizer.location(in: recognizer.view)
if let zone = TapZoneCalculator.zone(for: location, in: bounds, layout: tapSettings.layout) {
onDoubleTap?(zone)
}
}
@objc private func handleSingleTap(_ recognizer: UITapGestureRecognizer) {
// Single tap toggles controls visibility
onSingleTap?()
}
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: recognizer.view)
switch recognizer.state {
case .began:
// Reset state at the start of each pan
isRecognizedAsSeekGesture = false
seekGestureStartTranslation = .zero
case .changed:
// Check if we should recognize this as a seek gesture
if !isRecognizedAsSeekGesture {
let translationSize = CGSize(width: translation.x, height: translation.y)
if SeekGestureCalculator.isHorizontalMovement(translation: translationSize) {
// Recognize as seek gesture
isRecognizedAsSeekGesture = true
seekGestureStartTranslation = translation
onSeekGestureStarted?()
}
}
// If recognized, send updates
if isRecognizedAsSeekGesture {
let horizontalDelta = translation.x - seekGestureStartTranslation.x
onSeekGestureChanged?(horizontalDelta)
}
case .ended, .cancelled:
if isRecognizedAsSeekGesture {
let horizontalDelta = translation.x - seekGestureStartTranslation.x
onSeekGestureEnded?(horizontalDelta)
}
// Reset state
isRecognizedAsSeekGesture = false
seekGestureStartTranslation = .zero
default:
break
}
}
// MARK: - UIGestureRecognizerDelegate
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
// Single tap always allowed - it toggles controls visibility
if gestureRecognizer == singleTapRecognizer {
return true
}
// Double-tap only allowed when isActive (controls hidden) and tap gestures enabled
if gestureRecognizer == doubleTapRecognizer {
return isActive && tapSettings.isEnabled
}
// Pan gesture only allowed when isActive, seek enabled, content is seekable, and pinch not active
if gestureRecognizer == panRecognizer {
// Block seek gesture if pinch gesture is active
if isPinchGestureActive?() == true { return false }
// Block seek gesture if panel drag is active
if isPanelDragging?() == true { return false }
return isActive && seekSettings.isEnabled && isSeekable
}
return true
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
// Don't allow simultaneous recognition with other gestures
// to avoid conflicts with existing player gestures
false
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
// Single tap requires double tap to fail
if gestureRecognizer == singleTapRecognizer && otherGestureRecognizer == doubleTapRecognizer {
return true
}
// Single tap requires pan to fail
if gestureRecognizer == singleTapRecognizer && otherGestureRecognizer == panRecognizer {
return true
}
return false
}
}
#endif

View File

@@ -0,0 +1,117 @@
//
// PlayerGestureOverlay.swift
// Yattee
//
// SwiftUI overlay for handling player gestures.
//
#if os(iOS)
import SwiftUI
import UIKit
/// Overlay view that handles tap and seek gestures on the player.
struct PlayerGestureOverlay: View {
let settings: GesturesSettings
let isActive: Bool
let isSeekable: Bool
let onTapAction: (TapGestureAction, TapZonePosition) -> Void
let onSingleTap: () -> Void
let onSeekGestureStarted: () -> Void
let onSeekGestureChanged: (CGFloat) -> Void
let onSeekGestureEnded: (CGFloat) -> Void
/// Returns true if a pinch gesture is currently active (blocks seek gesture).
var isPinchGestureActive: (() -> Bool)? = nil
/// Returns true if panel drag is active (blocks seek gesture).
var isPanelDragging: (() -> Bool)? = nil
var body: some View {
GeometryReader { geometry in
GestureRecognizerView(
settings: settings,
bounds: CGRect(origin: .zero, size: geometry.size),
isActive: isActive,
isSeekable: isSeekable,
onDoubleTap: { position in
if let config = settings.tapGestures.configuration(for: position) {
onTapAction(config.action, position)
}
},
onSingleTap: onSingleTap,
onSeekGestureStarted: onSeekGestureStarted,
onSeekGestureChanged: onSeekGestureChanged,
onSeekGestureEnded: onSeekGestureEnded,
isPinchGestureActive: isPinchGestureActive,
isPanelDragging: isPanelDragging
)
}
// Always allow hit testing - single tap to toggle controls should work
// regardless of whether controls are visible. The coordinator handles
// disabling double-tap gestures when isActive is false.
.allowsHitTesting(true)
}
}
// MARK: - UIViewRepresentable
private struct GestureRecognizerView: UIViewRepresentable {
let settings: GesturesSettings
let bounds: CGRect
let isActive: Bool
let isSeekable: Bool
let onDoubleTap: (TapZonePosition) -> Void
let onSingleTap: () -> Void
let onSeekGestureStarted: () -> Void
let onSeekGestureChanged: (CGFloat) -> Void
let onSeekGestureEnded: (CGFloat) -> Void
var isPinchGestureActive: (() -> Bool)? = nil
var isPanelDragging: (() -> Bool)? = nil
func makeCoordinator() -> PlayerGestureCoordinator {
PlayerGestureCoordinator(
tapSettings: settings.tapGestures,
seekSettings: settings.seekGesture
)
}
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
let coordinator = context.coordinator
coordinator.isActive = isActive
coordinator.isSeekable = isSeekable
coordinator.onDoubleTap = onDoubleTap
coordinator.onSingleTap = onSingleTap
coordinator.onSeekGestureStarted = onSeekGestureStarted
coordinator.onSeekGestureChanged = onSeekGestureChanged
coordinator.onSeekGestureEnded = onSeekGestureEnded
coordinator.isPinchGestureActive = isPinchGestureActive
coordinator.isPanelDragging = isPanelDragging
coordinator.attach(to: view)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
let coordinator = context.coordinator
coordinator.tapSettings = settings.tapGestures
coordinator.seekSettings = settings.seekGesture
coordinator.bounds = bounds
coordinator.isActive = isActive
coordinator.isSeekable = isSeekable
// Update callbacks
coordinator.onDoubleTap = onDoubleTap
coordinator.onSingleTap = onSingleTap
coordinator.onSeekGestureStarted = onSeekGestureStarted
coordinator.onSeekGestureChanged = onSeekGestureChanged
coordinator.onSeekGestureEnded = onSeekGestureEnded
coordinator.isPinchGestureActive = isPinchGestureActive
coordinator.isPanelDragging = isPanelDragging
}
static func dismantleUIView(_ uiView: UIView, coordinator: PlayerGestureCoordinator) {
coordinator.detach()
}
}
#endif

View File

@@ -0,0 +1,281 @@
//
// TapGestureFeedbackView.swift
// Yattee
//
// Visual feedback overlay for tap gesture actions.
//
#if os(iOS)
import SwiftUI
/// Position for tap feedback display.
enum TapFeedbackPosition {
case left
case center
case right
/// Determines position based on action type (YouTube-style).
static func forAction(_ action: TapGestureAction) -> TapFeedbackPosition {
switch action {
case .seekBackward:
.left
case .seekForward:
.right
default:
.center
}
}
}
/// Visual feedback shown when a tap gesture is triggered.
struct TapGestureFeedbackView: View {
let action: TapGestureAction
let accumulatedSeconds: Int?
let onComplete: () -> Void
@State private var isVisible = false
@State private var scale: CGFloat = 0.8
@State private var dismissTask: Task<Void, Never>?
private var position: TapFeedbackPosition {
TapFeedbackPosition.forAction(action)
}
var body: some View {
GeometryReader { geometry in
HStack {
if position == .right {
Spacer()
}
feedbackContent
.frame(width: position == .center ? nil : geometry.size.width * 0.3)
.frame(maxWidth: position == .center ? 200 : nil)
if position == .left {
Spacer()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.opacity(isVisible ? 1 : 0)
.scaleEffect(scale)
.onAppear {
showAndScheduleDismiss()
}
.onChange(of: accumulatedSeconds) { _, _ in
// Reset dismiss timer when accumulated value changes (user tapped again)
scheduleDismiss()
}
.onDisappear {
dismissTask?.cancel()
}
}
private func showAndScheduleDismiss() {
withAnimation(.easeOut(duration: 0.15)) {
isVisible = true
scale = 1.0
}
scheduleDismiss()
}
private func scheduleDismiss() {
// Cancel any existing dismiss task
dismissTask?.cancel()
// Schedule new dismiss
dismissTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(1.0))
guard !Task.isCancelled else { return }
withAnimation(.easeIn(duration: 0.15)) {
isVisible = false
scale = 0.8
}
try? await Task.sleep(for: .seconds(0.15))
guard !Task.isCancelled else { return }
onComplete()
}
}
@ViewBuilder
private var feedbackContent: some View {
VStack(spacing: 8) {
Image(systemName: iconName)
.font(.system(size: 44, weight: .medium))
.foregroundStyle(.white)
if let text = feedbackText {
Text(text)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
}
}
.padding(20)
.background(
Circle()
.fill(Color.black.opacity(0.5))
.frame(width: 120, height: 120)
)
}
private var iconName: String {
switch action {
case .togglePlayPause:
"playpause.fill"
case .seekForward:
"arrow.trianglehead.clockwise"
case .seekBackward:
"arrow.trianglehead.counterclockwise"
case .toggleFullscreen:
"arrow.up.left.and.arrow.down.right"
case .togglePiP:
"pip"
case .playNext:
"forward.fill"
case .playPrevious:
"backward.fill"
case .cyclePlaybackSpeed:
"gauge.with.dots.needle.67percent"
case .toggleMute:
"speaker.slash.fill"
}
}
private var feedbackText: String? {
switch action {
case .seekForward(let seconds):
if let accumulated = accumulatedSeconds, accumulated != seconds {
return "+\(accumulated)s"
}
return "+\(seconds)s"
case .seekBackward(let seconds):
if let accumulated = accumulatedSeconds, accumulated != seconds {
return "-\(accumulated)s"
}
return "-\(seconds)s"
case .cyclePlaybackSpeed:
// This should be passed in from the action handler
return nil
default:
return nil
}
}
}
// MARK: - Seek Feedback (YouTube-style ripple)
/// YouTube-style seek feedback with multiple ripples.
struct SeekFeedbackView: View {
let isForward: Bool
let seconds: Int
let onComplete: () -> Void
@State private var rippleCount = 0
var body: some View {
GeometryReader { geometry in
HStack {
if isForward {
Spacer()
}
ZStack {
// Ripple circles
ForEach(0..<3) { index in
SeekRipple(
isForward: isForward,
delay: Double(index) * 0.1,
isActive: rippleCount > index
)
}
// Icon and text
VStack(spacing: 4) {
Image(systemName: isForward ? "arrow.trianglehead.clockwise" : "arrow.trianglehead.counterclockwise")
.font(.system(size: 32, weight: .medium))
.foregroundStyle(.white)
Text("\(isForward ? "+" : "-")\(seconds)s")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
}
}
.frame(width: geometry.size.width * 0.35, height: geometry.size.height)
if !isForward {
Spacer()
}
}
}
.onAppear {
// Animate ripples
withAnimation(.easeOut(duration: 0.1)) {
rippleCount = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation(.easeOut(duration: 0.1)) {
rippleCount = 2
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.easeOut(duration: 0.1)) {
rippleCount = 3
}
}
// Auto-dismiss
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
onComplete()
}
}
}
}
private struct SeekRipple: View {
let isForward: Bool
let delay: Double
let isActive: Bool
@State private var scale: CGFloat = 0.5
@State private var opacity: Double = 0
var body: some View {
Circle()
.fill(Color.white.opacity(0.2))
.scaleEffect(scale)
.opacity(opacity)
.onChange(of: isActive) { _, active in
if active {
withAnimation(.easeOut(duration: 0.3).delay(delay)) {
scale = 1.0
opacity = 0.3
}
withAnimation(.easeIn(duration: 0.5).delay(delay + 0.3)) {
opacity = 0
}
}
}
}
}
#Preview {
ZStack {
Color.black
TapGestureFeedbackView(
action: .seekForward(seconds: 10),
accumulatedSeconds: 30,
onComplete: {}
)
}
}
#endif

View File

@@ -0,0 +1,150 @@
//
// TapZoneCalculator.swift
// Yattee
//
// Calculator for determining tap zone hit-testing and frames.
//
#if os(iOS)
import CoreGraphics
import Foundation
/// Utility for calculating tap zone positions and hit-testing.
enum TapZoneCalculator {
/// Safe margin from screen edges to avoid system gesture conflicts.
static let safeMargin: CGFloat = 25
/// Determines which zone was tapped based on the layout and tap point.
/// - Parameters:
/// - point: The tap location in the bounds coordinate system.
/// - bounds: The total gesture-recognizable area bounds.
/// - layout: The current tap zone layout.
/// - Returns: The zone position that was tapped, or nil if outside safe margins.
static func zone(
for point: CGPoint,
in bounds: CGRect,
layout: TapZoneLayout
) -> TapZonePosition? {
// Check safe margins
let safeArea = bounds.insetBy(dx: safeMargin, dy: safeMargin)
guard safeArea.contains(point) else { return nil }
// Normalize point to 0-1 range within safe area
let normalizedX = (point.x - safeArea.minX) / safeArea.width
let normalizedY = (point.y - safeArea.minY) / safeArea.height
switch layout {
case .single:
return .full
case .horizontalSplit:
return normalizedX < 0.5 ? .left : .right
case .verticalSplit:
return normalizedY < 0.5 ? .top : .bottom
case .threeColumns:
if normalizedX < 1.0 / 3.0 {
return .leftThird
} else if normalizedX < 2.0 / 3.0 {
return .center
} else {
return .rightThird
}
case .quadrants:
let isTop = normalizedY < 0.5
let isLeft = normalizedX < 0.5
if isTop {
return isLeft ? .topLeft : .topRight
} else {
return isLeft ? .bottomLeft : .bottomRight
}
}
}
/// Returns the frame for a specific zone position within bounds.
/// - Parameters:
/// - position: The zone position.
/// - bounds: The total gesture-recognizable area bounds.
/// - layout: The current tap zone layout.
/// - Returns: The frame for the zone, or nil if position doesn't match layout.
static func frame(
for position: TapZonePosition,
in bounds: CGRect,
layout: TapZoneLayout
) -> CGRect? {
// Use safe area for calculations
let safeArea = bounds.insetBy(dx: safeMargin, dy: safeMargin)
switch layout {
case .single:
guard position == .full else { return nil }
return safeArea
case .horizontalSplit:
let halfWidth = safeArea.width / 2
switch position {
case .left:
return CGRect(x: safeArea.minX, y: safeArea.minY,
width: halfWidth, height: safeArea.height)
case .right:
return CGRect(x: safeArea.minX + halfWidth, y: safeArea.minY,
width: halfWidth, height: safeArea.height)
default:
return nil
}
case .verticalSplit:
let halfHeight = safeArea.height / 2
switch position {
case .top:
return CGRect(x: safeArea.minX, y: safeArea.minY,
width: safeArea.width, height: halfHeight)
case .bottom:
return CGRect(x: safeArea.minX, y: safeArea.minY + halfHeight,
width: safeArea.width, height: halfHeight)
default:
return nil
}
case .threeColumns:
let thirdWidth = safeArea.width / 3
switch position {
case .leftThird:
return CGRect(x: safeArea.minX, y: safeArea.minY,
width: thirdWidth, height: safeArea.height)
case .center:
return CGRect(x: safeArea.minX + thirdWidth, y: safeArea.minY,
width: thirdWidth, height: safeArea.height)
case .rightThird:
return CGRect(x: safeArea.minX + thirdWidth * 2, y: safeArea.minY,
width: thirdWidth, height: safeArea.height)
default:
return nil
}
case .quadrants:
let halfWidth = safeArea.width / 2
let halfHeight = safeArea.height / 2
switch position {
case .topLeft:
return CGRect(x: safeArea.minX, y: safeArea.minY,
width: halfWidth, height: halfHeight)
case .topRight:
return CGRect(x: safeArea.minX + halfWidth, y: safeArea.minY,
width: halfWidth, height: halfHeight)
case .bottomLeft:
return CGRect(x: safeArea.minX, y: safeArea.minY + halfHeight,
width: halfWidth, height: halfHeight)
case .bottomRight:
return CGRect(x: safeArea.minX + halfWidth, y: safeArea.minY + halfHeight,
width: halfWidth, height: halfHeight)
default:
return nil
}
}
}
}
#endif