Files
tvbox-Swift/tvbox/Views/Player/PlayerGestureLayer.swift
T
2026-05-14 17:50:06 +08:00

274 lines
8.5 KiB
Swift

#if os(iOS)
import SwiftUI
// MARK: - Gesture Mode Enum
/// 手势模式枚举 - 表示当前激活的手势类型
enum PlayerGestureMode: Equatable {
case none
case seeking(offset: Double)
case adjustingBrightness(delta: CGFloat)
case adjustingVolume(delta: CGFloat)
}
// MARK: - PlayerGestureLayer
/// 播放器手势交互层 - 覆盖在播放器上方的透明手势识别层
/// 支持水平滑动快进快退、左侧垂直滑动调节亮度、右侧垂直滑动调节音量、
/// 双击暂停/播放、捏合缩放
struct PlayerGestureLayer: View {
// MARK: - Callbacks
let onSeek: (Double) -> Void
let onTogglePlayPause: () -> Void
let onToggleControls: () -> Void
let onZoomChanged: (CGFloat) -> Void
// MARK: - Properties
let currentTime: Double
let duration: Double
// MARK: - State
@State private var gestureMode: PlayerGestureMode = .none
@State private var zoomScale: CGFloat = 1.0
@State private var showIndicator = false
@State private var dragStartX: CGFloat = 0
// MARK: - Pure Computation Functions
/// 根据起始位置和滑动方向确定手势类型
static func classifyGesture(
startX: CGFloat,
containerWidth: CGFloat,
translation: CGSize,
threshold: CGFloat = 10
) -> PlayerGestureMode {
let absH = abs(translation.width)
let absV = abs(translation.height)
// 水平优先判定(快进快退)
if absH > absV && absH > threshold {
// 根据滑动距离动态调整快进幅度,短距离精细,长距离快速
let normalizedOffset = translation.width / containerWidth
let offset = Double(normalizedOffset) * 120.0 // 滑满屏幕 = 120秒
return .seeking(offset: offset)
}
// 垂直判定:左半区域 = 亮度,右半区域 = 音量
if absV > threshold {
let isLeftHalf = startX < containerWidth / 2
let delta = -translation.height / 300.0 // 向上为正
if isLeftHalf {
return .adjustingBrightness(delta: delta)
} else {
return .adjustingVolume(delta: delta)
}
}
return .none
}
/// 计算快进快退目标时间(带边界钳制)
static func computeSeekTarget(
currentTime: Double,
offset: Double,
duration: Double
) -> Double {
let target = currentTime + offset
return max(0, min(target, duration))
}
/// 钳制调节值到 [0, 1] 范围
static func clampAdjustment(
currentValue: CGFloat,
delta: CGFloat
) -> CGFloat {
return max(0.0, min(1.0, currentValue + delta))
}
/// 钳制缩放值到 [minZoom, maxZoom] 范围
static func clampZoom(
scale: CGFloat,
minZoom: CGFloat = 1.0,
maxZoom: CGFloat = 3.0
) -> CGFloat {
return max(minZoom, min(maxZoom, scale))
}
// MARK: - Body
var body: some View {
GeometryReader { geometry in
Color.clear
.contentShape(Rectangle())
.gesture(dragGesture(in: geometry))
.gesture(pinchGesture)
.onTapGesture(count: 2) {
HapticManager.shared.lightImpact()
onTogglePlayPause()
}
.onTapGesture(count: 1) {
onToggleControls()
}
.overlay { gestureIndicatorOverlay }
}
}
// MARK: - Drag Gesture
private func dragGesture(in geometry: GeometryProxy) -> some Gesture {
DragGesture(minimumDistance: 5)
.onChanged { value in
if gestureMode == .none {
dragStartX = value.startLocation.x
HapticManager.shared.lightImpact()
}
let mode = Self.classifyGesture(
startX: dragStartX,
containerWidth: geometry.size.width,
translation: value.translation
)
if mode != .none {
gestureMode = mode
withAnimation(.easeInOut(duration: 0.2)) {
showIndicator = true
}
}
}
.onEnded { value in
switch gestureMode {
case .seeking(let offset):
let target = Self.computeSeekTarget(
currentTime: currentTime,
offset: offset,
duration: duration
)
onSeek(target - currentTime)
case .adjustingBrightness, .adjustingVolume:
break
case .none:
break
}
gestureMode = .none
withAnimation(.easeInOut(duration: 0.2)) {
showIndicator = false
}
}
}
// MARK: - Pinch Gesture
private var pinchGesture: some Gesture {
MagnifyGesture()
.onChanged { value in
let newScale = Self.clampZoom(scale: value.magnification * zoomScale)
onZoomChanged(newScale)
gestureMode = .none
withAnimation(.easeInOut(duration: 0.2)) {
showIndicator = true
}
}
.onEnded { value in
let finalScale = Self.clampZoom(scale: value.magnification * zoomScale)
if finalScale < 1.0 {
zoomScale = 1.0
} else {
zoomScale = finalScale
}
onZoomChanged(zoomScale)
withAnimation(.easeInOut(duration: 0.2)) {
showIndicator = false
}
}
}
// MARK: - Gesture Indicator Overlay
@ViewBuilder
private var gestureIndicatorOverlay: some View {
if showIndicator {
VStack(spacing: 8) {
indicatorIcon
indicatorText
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
.opacity(showIndicator ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showIndicator)
}
}
@ViewBuilder
private var indicatorIcon: some View {
switch gestureMode {
case .seeking(let offset):
Image(systemName: offset >= 0 ? "forward.fill" : "backward.fill")
.font(.title2)
.foregroundColor(.white)
case .adjustingBrightness:
Image(systemName: "sun.max.fill")
.font(.title2)
.foregroundColor(.yellow)
case .adjustingVolume:
Image(systemName: "speaker.wave.2.fill")
.font(.title2)
.foregroundColor(.white)
case .none:
EmptyView()
}
}
@ViewBuilder
private var indicatorText: some View {
switch gestureMode {
case .seeking(let offset):
let target = Self.computeSeekTarget(
currentTime: currentTime,
offset: offset,
duration: duration
)
Text(formatTime(target))
.font(.caption)
.foregroundColor(.white)
case .adjustingBrightness(let delta):
let percentage = Int(Self.clampAdjustment(
currentValue: UIScreen.main.brightness,
delta: delta
) * 100)
Text("\(percentage)%")
.font(.caption)
.foregroundColor(.white)
case .adjustingVolume(let delta):
let percentage = Int(Self.clampAdjustment(
currentValue: 0.5,
delta: delta
) * 100)
Text("\(percentage)%")
.font(.caption)
.foregroundColor(.white)
case .none:
EmptyView()
}
}
// MARK: - Helpers
private func formatTime(_ seconds: Double) -> String {
let totalSeconds = Int(max(0, seconds))
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let secs = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, secs)
} else {
return String(format: "%02d:%02d", minutes, secs)
}
}
}
#endif