mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
73
Yattee/Views/Player/Gestures/GestureSeekPreviewView.swift
Normal file
73
Yattee/Views/Player/Gestures/GestureSeekPreviewView.swift
Normal 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
|
||||
117
Yattee/Views/Player/Gestures/OverscrollGestureHandler.swift
Normal file
117
Yattee/Views/Player/Gestures/OverscrollGestureHandler.swift
Normal 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
|
||||
121
Yattee/Views/Player/Gestures/OverscrollGestureView.swift
Normal file
121
Yattee/Views/Player/Gestures/OverscrollGestureView.swift
Normal 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
|
||||
226
Yattee/Views/Player/Gestures/PlayerGestureCoordinator.swift
Normal file
226
Yattee/Views/Player/Gestures/PlayerGestureCoordinator.swift
Normal 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
|
||||
117
Yattee/Views/Player/Gestures/PlayerGestureOverlay.swift
Normal file
117
Yattee/Views/Player/Gestures/PlayerGestureOverlay.swift
Normal 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
|
||||
281
Yattee/Views/Player/Gestures/TapGestureFeedbackView.swift
Normal file
281
Yattee/Views/Player/Gestures/TapGestureFeedbackView.swift
Normal 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
|
||||
150
Yattee/Views/Player/Gestures/TapZoneCalculator.swift
Normal file
150
Yattee/Views/Player/Gestures/TapZoneCalculator.swift
Normal 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
|
||||
Reference in New Issue
Block a user