import SwiftUI import AVKit #if os(macOS) import AppKit #else import UIKit #endif /// 跨平台播放器:macOS 使用 AVPlayerView,避免 SwiftUI.VideoPlayer 在 macOS 的崩溃问题 struct PlatformVideoPlayer: View { let player: AVPlayer var body: some View { #if os(macOS) MacOSPlayerView(player: player) #else VideoPlayer(player: player) #endif } } #if os(macOS) private struct MacOSPlayerView: NSViewRepresentable { let player: AVPlayer func makeNSView(context: Context) -> AVPlayerView { let view = AVPlayerView() view.controlsStyle = .none // 禁用系统默认控制栏 view.showsFullScreenToggleButton = false view.videoGravity = .resizeAspect view.player = player return view } func updateNSView(_ nsView: AVPlayerView, context: Context) { if nsView.player !== player { nsView.player = player } } static func dismantleNSView(_ nsView: AVPlayerView, coordinator: ()) { nsView.player?.pause() nsView.player = nil } } #endif /// 视频播放器组件 - 对应 Android 版 PlayFragment struct PlayerView: View { let urlString: String var startPosition: Double = 0 var onProgressChanged: ((Double, Double?) -> Void)? = nil var onPlaybackEnded: (() -> Void)? = nil var onToggleFullScreen: (() -> Void)? = nil var canPlayNext: Bool = false var onPlayNext: (() -> Void)? = nil var vlcController: VLCPlayerController? = nil @AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue private var selectedEngine: PlayerEngine { PlayerEngine.fromStoredValue(playTypeRaw) } var body: some View { Group { switch selectedEngine { case .system: AVPlayerContentView( urlString: urlString, startPosition: startPosition, onProgressChanged: onProgressChanged, onPlaybackEnded: onPlaybackEnded, onToggleFullScreen: onToggleFullScreen, canPlayNext: canPlayNext, onPlayNext: onPlayNext ) case .vlc: VLCVodPlayerView( urlString: urlString, startPosition: startPosition, onProgressChanged: onProgressChanged, onPlaybackEnded: onPlaybackEnded, onToggleFullScreen: onToggleFullScreen, canPlayNext: canPlayNext, onPlayNext: onPlayNext, sharedController: vlcController ) } } .id(selectedEngine.rawValue) } } /// 基于系统 AVPlayer 的点播播放器实现 struct AVPlayerContentView: View { let urlString: String var startPosition: Double = 0 var onProgressChanged: ((Double, Double?) -> Void)? = nil var onPlaybackEnded: (() -> Void)? = nil var onToggleFullScreen: (() -> Void)? = nil var canPlayNext: Bool = false var onPlayNext: (() -> Void)? = nil @State private var player: AVPlayer? @State private var playbackEndObserver: NSObjectProtocol? @State private var timeObserverToken: Any? // UI 状态 @State private var isPlaying = false @State private var currentTime: Double = 0 @State private var duration: Double = 0 @State private var volume: Double = 1.0 @State private var rate: Float = 1.0 @State private var isPreparing = true @State private var showControls = true @State private var controlsTimer: Timer? @State private var osdIcon: String? @State private var osdOpacity: Double = 0 @State private var osdTimer: Timer? @State private var isDraggingProgress = false @State private var draggingSeconds: Double = 0 @State private var playerObservers: [NSKeyValueObservation] = [] var body: some View { ZStack { Group { if let player = player { PlatformVideoPlayer(player: player) } else { ZStack { Color.black ProgressView() .tint(.white) } } } .onTapGesture(count: 2) { onToggleFullScreen?() } .onTapGesture(count: 1) { togglePlayPauseWithOSD() } if isPreparing { ProgressView() .tint(.white) } if let osdIcon = osdIcon { Image(systemName: osdIcon) .font(.system(size: 60, weight: .semibold)) .foregroundColor(.white) .padding(30) .background(.ultraThinMaterial) .clipShape(Circle()) .opacity(osdOpacity) .allowsHitTesting(false) } } .overlay(alignment: .bottom) { GeometryReader { proxy in if player != nil { playbackControls(containerWidth: proxy.size.width) .opacity(showControls ? 1.0 : 0.0) .animation(.easeInOut(duration: 0.3), value: showControls) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } } } .overlay { SystemPlayerKeyboardCaptureView( onLeft: { seek(by: -seekStep) }, onRight: { seek(by: seekStep) }, onTogglePlayPause: { togglePlayPause() }, onToggleFullScreen: { onToggleFullScreen?() }, onVolumeDown: { wakeUpControls(); adjustVolume(by: -volumeStep) }, onVolumeUp: { wakeUpControls(); adjustVolume(by: volumeStep) } ) .frame(width: 1, height: 1) .opacity(0.01) .allowsHitTesting(false) } .onContinuousHover { phase in switch phase { case .active(_): wakeUpControls() case .ended: break } } .onAppear { setupPlayer() wakeUpControls() } .onChange(of: urlString) { _, _ in setupPlayer() wakeUpControls() } .onDisappear { cleanupPlayer() controlsTimer?.invalidate() osdTimer?.invalidate() } } private func setupPlayer() { guard let url = URL(string: urlString) else { return } // 清理旧播放器 cleanupPlayer() let playerItem = AVPlayerItem(url: url) let newPlayer = AVPlayer(playerItem: playerItem) // 设置监听 playerObservers = [ newPlayer.observe(\.timeControlStatus, options: [.new]) { p, _ in let status = p.timeControlStatus DispatchQueue.main.async { isPlaying = status == .playing } }, newPlayer.observe(\.reasonForWaitingToPlay, options: [.new]) { p, _ in let reason = p.reasonForWaitingToPlay DispatchQueue.main.async { isPreparing = reason != nil } }, newPlayer.observe(\.volume, options: [.new]) { p, _ in let vol = Double(p.volume) DispatchQueue.main.async { volume = vol } }, newPlayer.observe(\.rate, options: [.new]) { p, _ in let r = p.rate DispatchQueue.main.async { rate = r } } ] observePlaybackProgress(for: newPlayer) observePlaybackEnd(for: newPlayer) player = newPlayer startPlayback(for: newPlayer) } private func cleanupPlayer() { if let token = timeObserverToken { player?.removeTimeObserver(token) timeObserverToken = nil } if let observer = playbackEndObserver { NotificationCenter.default.removeObserver(observer) playbackEndObserver = nil } playerObservers.forEach { $0.invalidate() } playerObservers.removeAll() player?.pause() player?.replaceCurrentItem(with: nil) player = nil } private func startPlayback(for player: AVPlayer) { let target = max(startPosition, 0) if target > 0 { let seekTime = CMTime(seconds: target, preferredTimescale: 600) player.seek(to: seekTime, toleranceBefore: .zero, toleranceAfter: .zero) { _ in reportProgress(for: player) player.play() } } else { player.play() } } private func togglePlayPause() { guard let player = player else { return } if player.rate == 0 { player.play() } else { player.pause() } } private func togglePlayPauseWithOSD() { togglePlayPause() showOSD(icon: isPlaying ? "pause.fill" : "play.fill") } private func wakeUpControls() { withAnimation { showControls = true } controlsTimer?.invalidate() controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in withAnimation(.easeOut(duration: 0.5)) { showControls = false } } } private func showOSD(icon: String) { osdIcon = icon osdOpacity = 1.0 osdTimer?.invalidate() osdTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in withAnimation(.easeOut(duration: 0.5)) { osdOpacity = 0.0 } } } private func observePlaybackEnd(for player: AVPlayer) { guard let item = player.currentItem else { return } playbackEndObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main ) { _ in onPlaybackEnded?() } } private func observePlaybackProgress(for player: AVPlayer) { let interval = CMTime(seconds: 1, preferredTimescale: 2) timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { _ in reportProgress(for: player) } } private func reportProgress(for player: AVPlayer?) { guard let player else { return } let current = player.currentTime().seconds guard current.isFinite, current >= 0 else { return } if !isDraggingProgress { self.currentTime = current self.draggingSeconds = current } let rawDuration = player.currentItem?.duration.seconds if let rawDuration, rawDuration.isFinite, rawDuration >= 0 { self.duration = rawDuration } onProgressChanged?(current, duration > 0 ? duration : nil) } private var seekStep: Double { let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP) return Double(saved > 0 ? saved : 10) } private var volumeStep: Double { 0.1 } private var progressUpperBound: Double { max(duration, max(currentTime, 1)) } private func playbackControls(containerWidth: CGFloat) -> some View { VStack(spacing: 8) { // 第一行:进度条和时间 HStack(spacing: 12) { Text(currentTime.durationString) .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundColor(.white.opacity(0.9)) .lineLimit(1) .frame(width: 62, alignment: .leading) Slider( value: Binding( get: { isDraggingProgress ? draggingSeconds : currentTime }, set: { draggingSeconds = $0 wakeUpControls() } ), in: 0...progressUpperBound, onEditingChanged: { editing in isDraggingProgress = editing wakeUpControls() if !editing { seek(to: draggingSeconds) } } ) .accentColor(.white) .disabled(duration <= 0) Text(duration.durationString) .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundColor(.white.opacity(0.6)) .lineLimit(1) .frame(width: 62, alignment: .trailing) } .padding(.horizontal, 4) // 第二行:控制按钮 HStack(spacing: 0) { // 左侧区:倍速 HStack(spacing: 16) { playbackRateMenu } .frame(width: 150, alignment: .leading) Spacer() // 中间区:主控 (这里集成了您原本右下角的快进快退和下一集按钮) HStack(spacing: 24) { Button { wakeUpControls() seek(by: -seekStep) showOSD(icon: "gobackward.\(Int(seekStep))") } label: { Image(systemName: "gobackward.\(Int(seekStep))") .font(.system(size: 18, weight: .medium)) } .buttonStyle(.plain) Button { wakeUpControls() togglePlayPauseWithOSD() } label: { ZStack { Circle() .fill(Color.white.opacity(0.15)) .frame(width: 38, height: 38) Image(systemName: isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 18, weight: .bold)) } } .buttonStyle(.plain) Button { wakeUpControls() seek(by: seekStep) showOSD(icon: "goforward.\(Int(seekStep))") } label: { Image(systemName: "goforward.\(Int(seekStep))") .font(.system(size: 18, weight: .medium)) } .buttonStyle(.plain) if let onPlayNext { Button { guard canPlayNext else { return } wakeUpControls() onPlayNext() showOSD(icon: "forward.end.fill") } label: { Image(systemName: "forward.end.fill") .font(.system(size: 18, weight: .medium)) } .buttonStyle(.plain) .disabled(!canPlayNext) .opacity(canPlayNext ? 1 : 0.4) } } Spacer() // 右侧区:音量和全屏 HStack(spacing: 14) { HStack(spacing: 6) { Button { wakeUpControls() let newVolume = volume > 0 ? 0.0 : 1.0 player?.volume = Float(newVolume) showOSD(icon: newVolume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") } label: { Image(systemName: volumeIconName) .font(.system(size: 14, weight: .bold)) .frame(width: 20) } .buttonStyle(.plain) Slider( value: Binding( get: { volume }, set: { player?.volume = Float($0) wakeUpControls() } ), in: 0...1.0 ) .accentColor(.white.opacity(0.8)) .frame(width: 80) } if let onToggleFullScreen { Button { wakeUpControls() onToggleFullScreen() } label: { Image(systemName: "arrow.up.left.and.arrow.down.right") .font(.system(size: 15, weight: .bold)) } .buttonStyle(.plain) } } .frame(width: 150, alignment: .trailing) } } .padding(.horizontal, 24) .padding(.vertical, 10) .foregroundColor(.white) .glassCard(cornerRadius: 18) .padding(.horizontal, 20) .padding(.bottom, 6) .frame(width: containerWidth * 0.7) .environment(\.colorScheme, .dark) } private var playbackRateMenu: some View { Menu { ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { r in Button { wakeUpControls() player?.rate = Float(r) showOSD(icon: "speedometer") } label: { HStack { Text("\(String(format: "%.1f", r))x") if Float(r) == rate { Spacer() Image(systemName: "checkmark") } } } } } label: { HStack(spacing: 4) { Text("\(String(format: "%.1f", rate))x") Image(systemName: "chevron.up") .font(.system(size: 8, weight: .bold)) } .font(.system(size: 12, weight: .bold, design: .monospaced)) .foregroundColor(.white) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.white.opacity(0.12)) .clipShape(Capsule()) } .buttonStyle(.plain) } private var volumeIconName: String { if volume <= 0 { return "speaker.slash.fill" } if volume < 0.5 { return "speaker.wave.1.fill" } return "speaker.wave.2.fill" } private func seek(to seconds: Double) { guard let player = player else { return } let time = CMTime(seconds: seconds, preferredTimescale: 600) player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) } private func seek(by offset: Double) { guard let player else { return } let current = player.currentTime().seconds guard current.isFinite else { return } let wasPlaying = player.rate != 0 || player.timeControlStatus == .waitingToPlayAtSpecifiedRate var target = max(current + offset, 0) if let duration = player.currentItem?.duration.seconds, duration.isFinite { target = min(target, duration) } player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) { _ in reportProgress(for: player) if wasPlaying { player.play() } } } private func adjustVolume(by delta: Double) { guard let player else { return } let current = Double(player.volume) let target = min(max(current + delta, 0), 1) player.volume = Float(target) showOSD(icon: target <= 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") } } #if os(macOS) private struct SystemPlayerKeyboardCaptureView: NSViewRepresentable { let onLeft: () -> Void let onRight: () -> Void let onTogglePlayPause: () -> Void let onToggleFullScreen: () -> Void let onVolumeDown: () -> Void let onVolumeUp: () -> Void func makeNSView(context: Context) -> SystemPlayerKeyCaptureNSView { let view = SystemPlayerKeyCaptureNSView(frame: .zero) applyCallbacks(to: view) DispatchQueue.main.async { view.activate() } return view } func updateNSView(_ nsView: SystemPlayerKeyCaptureNSView, context: Context) { applyCallbacks(to: nsView) DispatchQueue.main.async { nsView.activate() } } private func applyCallbacks(to view: SystemPlayerKeyCaptureNSView) { view.onLeft = onLeft view.onRight = onRight view.onTogglePlayPause = onTogglePlayPause view.onToggleFullScreen = onToggleFullScreen view.onVolumeDown = onVolumeDown view.onVolumeUp = onVolumeUp } } private final class SystemPlayerKeyCaptureNSView: NSView { var onLeft: (() -> Void)? var onRight: (() -> Void)? var onTogglePlayPause: (() -> Void)? var onToggleFullScreen: (() -> Void)? var onVolumeDown: (() -> Void)? var onVolumeUp: (() -> Void)? override var acceptsFirstResponder: Bool { true } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() activate() } func activate() { window?.makeFirstResponder(self) } override func keyDown(with event: NSEvent) { if event.modifierFlags.intersection([.command, .control, .option]).isEmpty == false { super.keyDown(with: event) return } switch event.keyCode { case 123: // left onLeft?() return case 124: // right onRight?() return case 125: // down onVolumeDown?() return case 126: // up onVolumeUp?() return case 49: // space onTogglePlayPause?() return default: break } let key = event.charactersIgnoringModifiers?.lowercased() ?? "" switch key { case "k": onTogglePlayPause?() case "f": onToggleFullScreen?() default: super.keyDown(with: event) } } } #else private struct SystemPlayerKeyboardCaptureView: UIViewRepresentable { let onLeft: () -> Void let onRight: () -> Void let onTogglePlayPause: () -> Void let onToggleFullScreen: () -> Void let onVolumeDown: () -> Void let onVolumeUp: () -> Void func makeUIView(context: Context) -> SystemPlayerKeyCaptureUIView { let view = SystemPlayerKeyCaptureUIView(frame: .zero) applyCallbacks(to: view) DispatchQueue.main.async { view.activate() } return view } func updateUIView(_ uiView: SystemPlayerKeyCaptureUIView, context: Context) { applyCallbacks(to: uiView) DispatchQueue.main.async { uiView.activate() } } private func applyCallbacks(to view: SystemPlayerKeyCaptureUIView) { view.onLeft = onLeft view.onRight = onRight view.onTogglePlayPause = onTogglePlayPause view.onToggleFullScreen = onToggleFullScreen view.onVolumeDown = onVolumeDown view.onVolumeUp = onVolumeUp } } private final class SystemPlayerKeyCaptureUIView: UIView { var onLeft: (() -> Void)? var onRight: (() -> Void)? var onTogglePlayPause: (() -> Void)? var onToggleFullScreen: (() -> Void)? var onVolumeDown: (() -> Void)? var onVolumeUp: (() -> Void)? override var canBecomeFirstResponder: Bool { true } override var keyCommands: [UIKeyCommand]? { [ UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleLeft)), UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleRight)), UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleVolumeDown)), UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleVolumeUp)), UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleTogglePlayPause)), UIKeyCommand(input: "k", modifierFlags: [], action: #selector(handleTogglePlayPause)), UIKeyCommand(input: "f", modifierFlags: [], action: #selector(handleToggleFullScreen)) ] } override func didMoveToWindow() { super.didMoveToWindow() activate() } func activate() { becomeFirstResponder() } @objc private func handleLeft() { onLeft?() } @objc private func handleRight() { onRight?() } @objc private func handleVolumeDown() { onVolumeDown?() } @objc private func handleVolumeUp() { onVolumeUp?() } @objc private func handleTogglePlayPause() { onTogglePlayPause?() } @objc private func handleToggleFullScreen() { onToggleFullScreen?() } } #endif