import SwiftUI import AVFoundation import Combine import SwiftBricks /// 播放队列项 struct MediaItem: Identifiable, Equatable { let id: String var url: URL var name: String var mediaType: String // video/audio/stream 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 } } } /// PlayerBridge — 连接AVPlayer与BricksEngine /// 管理播放队列、音轨、全屏、循环模式 @MainActor final class PlayerBridge: ObservableObject { // MARK: - Published状态 @Published var engine: BricksEngine? @Published var schema: ControlSchema? @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 // MARK: - 内部状态 let player = AVPlayer() var queue: [MediaItem] = [] var currentIndex: Int = -1 var repeatMode: RepeatMode = .all private var timeObserver: Any? private var endObserver: Any? private var itemStatusObserver: NSKeyValueObservation? private var cancellables = Set() // 视频全屏 private var fullscreenWindow: NSWindow? // 缓存时长 private var cachedDuration: Double = 0 // 用户偏好音轨(跨歌曲保持) private var preferredTrackIndex: Int = 0 // MARK: - 初始化 func setup() { let eng = BricksEngine() // 注册自定义widget eng.registerWidget(type: "VideoPlayer") { [weak self] schema, engine in AnyView(VideoPlayerWidget(bridge: self!, schema: schema, engine: engine)) } eng.registerWidget(type: "ProgressSlider") { [weak self] schema, engine in AnyView(ProgressSliderWidget(bridge: self!, schema: schema, engine: engine)) } engine = eng // 加载player.ui loadPlayerUI(engine: eng) // 注册事件监听 registerEvents(engine: eng) // 设置播放结束监听 setupEndObserver() // 设置时间监听 setupTimeObserver() } // MARK: - 加载UI定义文件 private func loadPlayerUI(engine: BricksEngine) { // 从Bundle加载player.ui let bundle = Bundle.module if let url = bundle.url(forResource: "player", withExtension: "ui"), let data = try? Data(contentsOf: url), let json = String(data: data, encoding: .utf8) { do { try engine.loadJSON(json) schema = engine.rootSchema } catch { showToast("JSON加载失败: \(error.localizedDescription)") } } else { // Fallback: 内嵌JSON let fallback = """ {"id":"app","widgettype":"VBox","options":{"width":"100%","height":"100%"},"subwidgets":[ {"widgettype":"VideoPlayer","id":"video_player","options":{"width":"100%","bgcolor":"#000"}}, {"widgettype":"HBox","options":{"spacing":8,"alignItems":"center","padding":"8px"}, "subwidgets":[ {"widgettype":"Button","id":"btn_prev","options":{"label":"⏮","css":"text"}, "binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.prev"}]}, {"widgettype":"Button","id":"btn_play","options":{"label":"▶️","css":"text"}, "binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.toggle"}]}, {"widgettype":"Button","id":"btn_next","options":{"label":"⏭","css":"text"}, "binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.next"}]}, {"widgettype":"Filler"}, {"widgettype":"Button","id":"btn_repeat","options":{"label":"🔁","css":"text"}, "binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.cycle_repeat"}]}, {"widgettype":"Button","id":"btn_fullscreen","options":{"label":"⛶","css":"text"}, "binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.fullscreen"}]} ]}, {"widgettype":"Text","id":"playlist_info","options":{"text":"播放列表: 空"}} ]} """ do { try engine.loadJSON(fallback) schema = engine.rootSchema } catch { showToast("Fallback JSON失败: \(error)") } } } // MARK: - 事件注册 private func registerEvents(engine: BricksEngine) { let bus = engine.eventBus bus.on("player.toggle") { [weak self] _ in self?.togglePlayPause() } bus.on("player.prev") { [weak self] _ in self?.playPrev() } bus.on("player.next") { [weak self] _ in self?.playNext() } bus.on("player.cycle_repeat") { [weak self] _ in self?.cycleRepeatMode() } bus.on("player.fullscreen") { [weak self] _ in self?.toggleFullscreen() } bus.on("player.show_tracks") { [weak self] _ in self?.showTrackDialog = true } bus.on("player.add_url") { [weak self] data in if let url = data["url"] as? String, !url.isEmpty { self?.addURL(url) } } bus.on("player.open_file") { [weak self] _ in self?.openFileDialog() } bus.on("player.play_selected") { [weak self] data in if let index = data["index"] as? Int { self?.playIndex(index) } } } // MARK: - 播放控制 func togglePlayPause() { if player.timeControlStatus == .playing { player.pause() } else { player.play() } } 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: let idx = (currentIndex + 1) % queue.count playIndex(idx) } } 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() // 重置缓存时长 cachedDuration = 0 // 监听item状态,加载时长 itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] item, _ in if item.status == .readyToPlay { Task { @MainActor in self?.loadDuration(item) } } } // 加载音轨信息 loadTrackInfo(playerItem) // 更新UI updatePlayButton(isPlaying: true) updatePlaylistHighlight() 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 { updatePlayButton(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) { guard let eng = engine, let item = player.currentItem else { return } let current = time.seconds var total = item.duration.seconds // 如果duration还没加载好,尝试缓存值 if !total.isFinite || total <= 0 { total = cachedDuration } else { cachedDuration = total } if total.isFinite && total > 0 { eng.store.setValue(id: "time_current", value: formatTime(current)) eng.store.setValue(id: "time_total", value: formatTime(total)) eng.store.setValue(id: "progress_slider", value: "\(current)/\(total)") } else { eng.store.setValue(id: "time_current", value: formatTime(current)) } } 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 engine?.store.setValue(id: "time_total", value: formatTime(secs)) } } } } private func formatTime(_ seconds: Double) -> String { guard seconds.isFinite && seconds >= 0 else { return "00:00" } let m = Int(seconds) / 60 let s = Int(seconds) % 60 return String(format: "%02d:%02d", m, s) } // 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() { let label = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))" engine?.store.setValue(id: "track_label", value: label) } // MARK: - 循环模式 func cycleRepeatMode() { repeatMode = repeatMode.next let label = "\(repeatMode.icon) \(repeatMode.rawValue)" engine?.store.setValue(id: "btn_repeat", value: label) showToast("循环模式: \(repeatMode.rawValue)") } // MARK: - 全屏(视频内容全屏,非窗口全屏) func toggleFullscreen() { #if os(macOS) if let fw = fullscreenWindow { fw.close() fullscreenWindow = nil isFullscreen = false 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 win.ignoresMouseEvents = false let hostView = NSHostingView(rootView: VideoPlayerRepresentable(player: player) .background(Color.black) .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) .onTapGesture { [weak self] in self?.toggleFullscreen() } ) hostView.frame = screen.frame win.contentView = hostView win.makeKeyAndOrderFront(nil) NSApp.hide(nil) 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") } } #elseif os(iOS) showURLDialog = true // iOS用URL输入代替文件选择 #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) { let item = MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type) queue.append(item) updatePlaylist() // 如果队列为空或只有一个,自动播放 if queue.count == 1 { playIndex(0) } } // MARK: - 播放列表UI更新 private func updatePlaylist() { guard let eng = engine else { return } // 更新playlist_panel的内容 if queue.isEmpty { eng.store.setValue(id: "playlist_empty", value: "暂无媒体,请添加文件或URL") } else { let list = queue.enumerated().map { idx, item in "\(idx + 1). \(item.name) [\(item.mediaType)]" }.joined(separator: "\n") eng.store.setValue(id: "playlist_empty", value: list) } eng.store.setValue(id: "playlist_info", value: "播放列表: \(queue.count) 项") } private func updatePlayButton(isPlaying: Bool) { let label = isPlaying ? "⏸" : "▶️" engine?.store.setValue(id: "btn_play", value: label) } private func updatePlaylistHighlight() { // 高亮当前播放项(简化实现) } // MARK: - Toast private func showToast(_ message: String) { toastMessage = message DispatchQueue.main.asyncAfter(deadline: .now() + 2) { if self.toastMessage == message { self.toastMessage = nil } } } deinit { if let observer = timeObserver { player.removeTimeObserver(observer) } itemStatusObserver?.invalidate() } }