import SwiftUI import AVFoundation import Combine #if os(macOS) import AppKit #endif /// 播放队列项 struct MediaItem: Identifiable, Equatable { let id: String var url: URL var name: String var mediaType: String static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id } } /// 循环模式 enum RepeatMode: String, CaseIterable { case none = "不循环" case single = "单曲循环" case all = "列表循环" var icon: String { switch self { case .none: return "➡️" case .single: return "🔂" case .all: return "🔁" } } var next: RepeatMode { switch self { case .none: return .single case .single: return .all case .all: return .none } } } @MainActor final class PlayerBridge: ObservableObject { // MARK: - Published状态 @Published var showURLDialog = false @Published var showTrackDialog = false @Published var toastMessage: String? @Published var availableTracks: [String] = [] @Published var currentTrackIndex: Int = 0 @Published var isFullscreen = false @Published var showToolbar = false @Published var isPlaying = false @Published var currentTimeText = "00:00" @Published var totalTimeText = "00:00" @Published var currentTrackLabel = "🎵 Track 1" @Published var volume: Float = 1.0 @Published var progressRatio: Double = 0 @Published var cachedDuration: Double = 0 @Published var queue: [MediaItem] = [] @Published var currentIndex: Int = -1 @Published var repeatMode: RepeatMode = .all // MARK: - 内部状态 let player = AVPlayer() private var timeObserver: Any? private var itemStatusObserver: NSKeyValueObservation? private var cancellables = Set() private var preferredTrackIndex: Int = 0 #if os(macOS) private var fullscreenWindow: NSWindow? private var playlistWindow: NSWindow? #endif // MARK: - 初始化 func setup() { player.volume = volume setupTimeObserver() setupPlaybackStatusObserver() setupEndObserver() } // MARK: - 播放控制 func togglePlayPause() { if player.timeControlStatus == .playing { player.pause() isPlaying = false } else { player.play() isPlaying = true } } func playPrev() { guard !queue.isEmpty else { return } let idx = (currentIndex - 1 + queue.count) % queue.count playIndex(idx) } func playNext() { guard !queue.isEmpty else { return } switch repeatMode { case .none: if currentIndex < queue.count - 1 { playIndex(currentIndex + 1) } case .single: player.seek(to: .zero); player.play() case .all: playIndex((currentIndex + 1) % queue.count) } } func playIndex(_ index: Int) { guard index >= 0 && index < queue.count else { return } currentIndex = index let item = queue[index] let playerItem = AVPlayerItem(url: item.url) player.replaceCurrentItem(with: playerItem) player.play() isPlaying = true cachedDuration = 0 progressRatio = 0 currentTimeText = "00:00" totalTimeText = "00:00" itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in if pi.status == .readyToPlay { Task { @MainActor in self?.loadDuration(pi) } } } loadTrackInfo(playerItem) showToast("正在播放: \(item.name)") } // MARK: - 播放结束 private func setupEndObserver() { NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor in self?.onPlaybackEnded() } } } private func onPlaybackEnded() { switch repeatMode { case .none: if currentIndex < queue.count - 1 { playNext() } else { isPlaying = false } case .single: player.seek(to: .zero); player.play() case .all: playNext() } } // MARK: - 时间更新 private func setupTimeObserver() { let interval = CMTime(seconds: 0.5, preferredTimescale: 600) timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in Task { @MainActor in self?.updateTimeDisplay(time: time) } } } private func updateTimeDisplay(time: CMTime) { let current = time.seconds var total = player.currentItem?.duration.seconds ?? 0 if !total.isFinite || total <= 0 { total = cachedDuration } else { cachedDuration = total } currentTimeText = formatTime(current) if total.isFinite && total > 0 { totalTimeText = formatTime(total) progressRatio = max(0, min(1, current / total)) } } private func loadDuration(_ item: AVPlayerItem) { Task { if let dur = try? await item.asset.load(.duration) { let secs = dur.seconds if secs.isFinite && secs > 0 { cachedDuration = secs totalTimeText = formatTime(secs) } } } } private func formatTime(_ seconds: Double) -> String { guard seconds.isFinite && seconds >= 0 else { return "00:00" } let h = Int(seconds) / 3600 let m = (Int(seconds) % 3600) / 60 let s = Int(seconds) % 60 if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) } return String(format: "%02d:%02d", m, s) } // MARK: - 音量 func adjustVolume(by delta: Float) { volume = max(0, min(1, volume + delta)) player.volume = volume } func setVolume(_ val: Float) { volume = max(0, min(1, val)) player.volume = volume } // MARK: - 音轨 private func loadTrackInfo(_ item: AVPlayerItem) { Task { guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible) else { availableTracks = ["Track 1"] currentTrackIndex = 0 updateTrackLabel() return } let options = group.options availableTracks = options.enumerated().map { idx, _ in "Track \(idx + 1)" } var targetIndex = preferredTrackIndex if targetIndex >= options.count { targetIndex = 0 } item.select(options[targetIndex], in: group) currentTrackIndex = targetIndex updateTrackLabel() } } func selectTrack(index: Int) { guard let item = player.currentItem else { return } Task { guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible), index < group.options.count else { return } item.select(group.options[index], in: group) currentTrackIndex = index preferredTrackIndex = index updateTrackLabel() } } private func updateTrackLabel() { currentTrackLabel = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))" } // MARK: - 循环模式 func cycleRepeatMode() { repeatMode = repeatMode.next showToast("循环模式: \(repeatMode.rawValue)") } // MARK: - 全屏(视频内容全屏,非窗口全屏) func toggleFullscreen() { #if os(macOS) if let fw = fullscreenWindow { fw.close() fullscreenWindow = nil isFullscreen = false NSApp.activate(ignoringOtherApps: true) return } guard let screen = NSScreen.main else { return } let win = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false) win.level = .screenSaver win.backgroundColor = .black win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] win.hasShadow = false // 使用纯 NSView + AVPlayerLayer,避免 SwiftUI 桥接崩溃 let playerView = FullscreenPlayerView(frame: screen.frame) playerView.player = player win.contentView = playerView win.makeKeyAndOrderFront(nil) // 确保键盘事件(Escape退出)能被接收 win.makeFirstResponder(playerView) NSApp.activate(ignoringOtherApps: true) fullscreenWindow = win isFullscreen = true #endif } // MARK: - 文件操作 func openFileDialog() { #if os(macOS) let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = true panel.allowedContentTypes = [ .movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .avi, .mp3, .wav, .mpeg4Audio ] if panel.runModal() == .OK { for url in panel.urls { addItem(url: url, name: url.lastPathComponent, type: "file") } } #endif } func addURL(_ urlString: String) { guard let url = URL(string: urlString) else { showToast("无效URL"); return } let name = url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent let type = urlString.contains(".m3u8") ? "stream" : "url" addItem(url: url, name: name, type: type) } private func addItem(url: URL, name: String, type: String) { queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type)) if queue.count == 1 { playIndex(0) } } func removeItem(at index: Int) { guard index >= 0 && index < queue.count else { return } let wasPlaying = (index == currentIndex) queue.remove(at: index) if wasPlaying { if queue.isEmpty { currentIndex = -1 player.replaceCurrentItem(with: nil) currentTimeText = "00:00"; totalTimeText = "00:00"; progressRatio = 0 } else { currentIndex = min(index, queue.count - 1) playIndex(currentIndex) } } else if index < currentIndex { currentIndex -= 1 } } // MARK: - 播放列表窗口 @Published var showPlaylistSheet = false func togglePlaylistWindow() { #if os(macOS) if let win = playlistWindow, win.isVisible { win.close(); playlistWindow = nil; return } let win = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 400, height: 500), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false ) win.title = "播放列表 (\(queue.count))" win.isReleasedWhenClosed = false win.center() win.contentView = NSHostingView(rootView: PlaylistWindowView(bridge: self)) win.makeKeyAndOrderFront(nil) playlistWindow = win #else showPlaylistSheet.toggle() #endif } // MARK: - Toast private func showToast(_ message: String) { toastMessage = message DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in if self?.toastMessage == message { self?.toastMessage = nil } } } // MARK: - 播放状态 private func setupPlaybackStatusObserver() { player.publisher(for: \.timeControlStatus) .receive(on: DispatchQueue.main) .sink { [weak self] status in self?.isPlaying = (status == .playing) } .store(in: &cancellables) } deinit { if let observer = timeObserver { player.removeTimeObserver(observer) } itemStatusObserver?.invalidate() } } // MARK: - 全屏专用 NSView(纯 AppKit,不经过 SwiftUI) #if os(macOS) import AppKit class FullscreenPlayerView: NSView { var player: AVPlayer? { get { (layer as? AVPlayerLayer)?.player } set { (layer as? AVPlayerLayer)?.player = newValue } } override init(frame: NSRect) { super.init(frame: frame) wantsLayer = true let playerLayer = AVPlayerLayer() playerLayer.videoGravity = .resizeAspect layer = playerLayer } required init?(coder: NSCoder) { fatalError() } override var acceptsFirstResponder: Bool { true } override func layout() { super.layout() (layer as? AVPlayerLayer)?.frame = bounds } override func mouseDown(with event: NSEvent) { if event.clickCount == 2 { // 双击退出全屏 — 通过通知 NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil) } else { // 单击播放/暂停 if let p = player { if p.timeControlStatus == .playing { p.pause() } else { p.play() } } } } override func keyDown(with event: NSEvent) { if event.keyCode == 53 { // Escape NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil) } } } #endif