#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