Files
yattee/Yattee/Views/Player/Gestures/TapGestureFeedbackView.swift
2026-02-08 18:33:56 +01:00

282 lines
7.6 KiB
Swift

//
// 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