mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
282 lines
7.6 KiB
Swift
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
|