// // WakeVideoPlayer.swift // wake // // Created by Cascade on 2025/9/12. // import SwiftUI import AVKit /// 一个遵循项目 Theme 风格的 SwiftUI 视频播放组件。 /// 支持:播放/暂停、进度条、静音、全屏、自动隐藏控件、自动播放与循环。 public struct WakeVideoPlayer: View { // MARK: - Public Config private let url: URL private let autoPlay: Bool private let isLooping: Bool private let showsControls: Bool private let allowFullscreen: Bool private let muteInitially: Bool private let videoGravity: AVLayerVideoGravity // MARK: - Internal State @State private var player: AVPlayer = AVPlayer() @State private var isPlaying: Bool = false @State private var isMuted: Bool = false @State private var duration: Double = 0 @State private var currentTime: Double = 0 @State private var isScrubbing: Bool = false @State private var isControlsVisible: Bool = true @State private var isFullscreen: Bool = false @State private var timeObserverToken: Any? @State private var endObserver: Any? // 自动隐藏控件的定时器 @State private var autoHideWorkItem: DispatchWorkItem? public init( url: URL, autoPlay: Bool = true, isLooping: Bool = false, showsControls: Bool = true, allowFullscreen: Bool = true, muteInitially: Bool = false, videoGravity: AVLayerVideoGravity = .resizeAspectFill ) { self.url = url self.autoPlay = autoPlay self.isLooping = isLooping self.showsControls = showsControls self.allowFullscreen = allowFullscreen self.muteInitially = muteInitially self.videoGravity = videoGravity } public var body: some View { ZStack { VideoPlayerRepresentable(player: player, videoGravity: videoGravity) .background(Color.black) .onTapGesture { toggleControls() } if showsControls && isControlsVisible { controlsOverlay .transition(.opacity) } } .onAppear(perform: setup) .onDisappear(perform: cleanup) .fullScreenCover(isPresented: $isFullscreen) { FullscreenContainer( player: player, isPlaying: $isPlaying, isMuted: $isMuted, duration: $duration, currentTime: $currentTime, isScrubbing: $isScrubbing, onTogglePlay: togglePlay, onSeek: seek(to:), onMute: toggleMute, onDismiss: { isFullscreen = false }, videoGravity: videoGravity ) } } } // MARK: - UI private extension WakeVideoPlayer { var controlsOverlay: some View { VStack(spacing: 0) { // Top gradient (可按需添加标题/返回等) LinearGradient(colors: [Color.black.opacity(0.35), .clear], startPoint: .top, endPoint: .bottom) .frame(height: 80) .frame(maxWidth: .infinity) .allowsHitTesting(false) Spacer() // Center play/pause button(大按钮便于点按) Button(action: togglePlay) { Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill") .resizable() .frame(width: 64, height: 64) .foregroundColor(.white) .shadow(color: Theme.Shadows.large, radius: 12, x: 0, y: 8) } .padding(.bottom, Theme.Spacing.lg) // Bottom bar controls VStack(spacing: Theme.Spacing.sm) { HStack { Text(formatTime(currentTime)) .font(.caption) .foregroundColor(.white.opacity(0.85)) .frame(width: 46, alignment: .leading) Slider(value: Binding( get: { currentTime }, set: { newVal in currentTime = min(max(0, newVal), duration) } ), in: 0...max(duration, 0.01), onEditingChanged: { editing in isScrubbing = editing if !editing { seek(to: currentTime) } }) .tint(Theme.Colors.primary) Text(formatTime(duration)) .font(.caption) .foregroundColor(.white.opacity(0.85)) .frame(width: 46, alignment: .trailing) } HStack(spacing: Theme.Spacing.lg) { Button(action: toggleMute) { Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") .font(.system(size: 16, weight: .semibold)) .foregroundColor(.white) } Spacer() if allowFullscreen { Button(action: { isFullscreen = true }) { Image(systemName: "arrow.up.left.and.down.right.magnifyingglass") .font(.system(size: 16, weight: .semibold)) .foregroundColor(.white) } } } } .padding(.horizontal, Theme.Spacing.lg) .padding(.top, Theme.Spacing.sm) .padding(.bottom, Theme.Spacing.lg + 4) .background( LinearGradient(colors: [Color.black.opacity(0.0), Color.black.opacity(0.55)], startPoint: .top, endPoint: .bottom) .edgesIgnoringSafeArea(.bottom) ) } .onAppear { scheduleAutoHideIfNeeded() } .onChange(of: isPlaying) { _, _ in scheduleAutoHideIfNeeded() } } } // MARK: - Lifecycle & Player Setup private extension WakeVideoPlayer { func setup() { // 配置 Player let item = AVPlayerItem(url: url) player.replaceCurrentItem(with: item) player.isMuted = muteInitially isMuted = muteInitially // 监听时长 let cmDuration = item.asset.duration.secondsNonNaN if cmDuration.isFinite { duration = cmDuration } // 时间观察者 addTimeObserver() // 循环播放 if isLooping { endObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main ) { _ in player.seek(to: .zero) if autoPlay { player.play() } } } // 自动播放 if autoPlay { player.play() isPlaying = true scheduleAutoHideIfNeeded() } } func cleanup() { if let token = timeObserverToken { player.removeTimeObserver(token) timeObserverToken = nil } if let endObs = endObserver { NotificationCenter.default.removeObserver(endObs) endObserver = nil } autoHideWorkItem?.cancel() autoHideWorkItem = nil player.pause() } func addTimeObserver() { // 每 0.5s 回调一次 let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in guard !isScrubbing else { return } currentTime = time.secondsNonNaN if duration <= 0 { if let cm = player.currentItem?.duration { let total = cm.secondsNonNaN if total.isFinite { duration = total } } } } } } // MARK: - Actions private extension WakeVideoPlayer { func togglePlay() { if isPlaying { player.pause() isPlaying = false showControls() } else { player.play() isPlaying = true scheduleAutoHideIfNeeded() } } func toggleMute() { isMuted.toggle() player.isMuted = isMuted showControls() scheduleAutoHideIfNeeded() } func seek(to seconds: Double) { let clamped = min(max(0, seconds), max(duration, 0)) let time = CMTime(seconds: clamped, preferredTimescale: 600) player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) if isPlaying { scheduleAutoHideIfNeeded() } } func toggleControls() { withAnimation(.easeInOut(duration: 0.2)) { isControlsVisible.toggle() } if isControlsVisible { scheduleAutoHideIfNeeded() } } func showControls() { withAnimation(.easeInOut(duration: 0.2)) { isControlsVisible = true } } func scheduleAutoHideIfNeeded() { autoHideWorkItem?.cancel() guard showsControls && isPlaying else { return } let work = DispatchWorkItem { withAnimation(.easeOut(duration: 0.25)) { isControlsVisible = false } } autoHideWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + 2.5, execute: work) } } // MARK: - Helpers private extension WakeVideoPlayer { func formatTime(_ seconds: Double) -> String { guard seconds.isFinite && !seconds.isNaN else { return "00:00" } let total = Int(seconds) let h = total / 3600 let m = (total % 3600) / 60 let s = (total % 60) if h > 0 { return String(format: "%02d:%02d:%02d", h, m, s) } else { return String(format: "%02d:%02d", m, s) } } } // MARK: - Representable: 控制 videoGravity private struct VideoPlayerRepresentable: UIViewControllerRepresentable { let player: AVPlayer let videoGravity: AVLayerVideoGravity func makeUIViewController(context: Context) -> AVPlayerViewController { let vc = AVPlayerViewController() vc.player = player vc.showsPlaybackControls = false vc.videoGravity = videoGravity return vc } func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { uiViewController.player = player uiViewController.videoGravity = videoGravity } } // MARK: - Fullscreen Container private struct FullscreenContainer: View { let player: AVPlayer @Binding var isPlaying: Bool @Binding var isMuted: Bool @Binding var duration: Double @Binding var currentTime: Double @Binding var isScrubbing: Bool let onTogglePlay: () -> Void let onSeek: (Double) -> Void let onMute: () -> Void let onDismiss: () -> Void let videoGravity: AVLayerVideoGravity @State private var isControlsVisible: Bool = true @State private var autoHideWorkItem: DispatchWorkItem? var body: some View { ZStack { VideoPlayerRepresentable(player: player, videoGravity: videoGravity) .ignoresSafeArea() .background(Color.black) .onTapGesture { toggleControls() } if isControlsVisible { VStack(spacing: 0) { HStack { Button(action: onDismiss) { Image(systemName: "xmark.circle.fill") .font(.system(size: 24, weight: .semibold)) .foregroundColor(.white) .shadow(color: Theme.Shadows.large, radius: 12, x: 0, y: 8) } Spacer() } .padding(.horizontal, Theme.Spacing.lg) .padding(.top, Theme.Spacing.lg) .padding(.bottom, Theme.Spacing.md) .background( LinearGradient(colors: [Color.black.opacity(0.55), .clear], startPoint: .top, endPoint: .bottom) .ignoresSafeArea(edges: .top) ) Spacer() VStack(spacing: Theme.Spacing.sm) { HStack { Text(formatTime(currentTime)) .font(.caption) .foregroundColor(.white.opacity(0.85)) .frame(width: 46, alignment: .leading) Slider(value: Binding( get: { currentTime }, set: { newVal in currentTime = min(max(0, newVal), duration) } ), in: 0...max(duration, 0.01), onEditingChanged: { editing in isScrubbing = editing if !editing { onSeek(currentTime) } }) .tint(Theme.Colors.primary) Text(formatTime(duration)) .font(.caption) .foregroundColor(.white.opacity(0.85)) .frame(width: 46, alignment: .trailing) } HStack(spacing: Theme.Spacing.lg) { Button(action: onTogglePlay) { Image(systemName: isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 16, weight: .semibold)) .foregroundColor(.white) } Button(action: onMute) { Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") .font(.system(size: 16, weight: .semibold)) .foregroundColor(.white) } Spacer() } } .padding(.horizontal, Theme.Spacing.lg) .padding(.top, Theme.Spacing.sm) .padding(.bottom, Theme.Spacing.lg + 4) .background( LinearGradient(colors: [Color.black.opacity(0.0), Color.black.opacity(0.7)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea(edges: .bottom) ) } .transition(.opacity) } } .onAppear { scheduleAutoHideIfNeeded() } .onChange(of: isPlaying) { _ in scheduleAutoHideIfNeeded() } } func toggleControls() { withAnimation(.easeInOut(duration: 0.2)) { isControlsVisible.toggle() } if isControlsVisible { scheduleAutoHideIfNeeded() } } func scheduleAutoHideIfNeeded() { autoHideWorkItem?.cancel() guard isPlaying else { return } let work = DispatchWorkItem { withAnimation(.easeOut(duration: 0.25)) { isControlsVisible = false } } autoHideWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + 2.5, execute: work) } func formatTime(_ seconds: Double) -> String { guard seconds.isFinite && !seconds.isNaN else { return "00:00" } let total = Int(seconds) let h = total / 3600 let m = (total % 3600) / 60 let s = (total % 60) if h > 0 { return String(format: "%02d:%02d:%02d", h, m, s) } else { return String(format: "%02d:%02d", m, s) } } } // MARK: - CMTime helpers private extension CMTime { var secondsNonNaN: Double { let s = CMTimeGetSeconds(self) if s.isNaN || s.isInfinite { return 0 } return s } } // MARK: - Preview #Preview("WakeVideoPlayer - Basic") { VStack(spacing: 16) { Text("WakeVideoPlayer 预览") .font(.headline) WakeVideoPlayer( url: URL(string: "https://cdn.memorywake.com/users/7350439663116619888/files/7361241959983353857/7361241920703696897.mp4")!, autoPlay: false, isLooping: true, showsControls: true, allowFullscreen: true, muteInitially: true, videoGravity: .resizeAspectFill ) .frame(height: 220) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large, style: .continuous)) .shadow(color: Theme.Shadows.cardShadow.color, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y) .padding() } .background(Theme.Colors.background) }