diff --git a/Package.swift b/Package.swift index 08f73ee..6bb7ffe 100644 --- a/Package.swift +++ b/Package.swift @@ -10,16 +10,11 @@ let package = Package( products: [ .executable(name: "MiniPlayer", targets: ["MiniPlayer"]) ], - dependencies: [ - .package(path: "../SwiftBricks") - ], + dependencies: [], targets: [ .executableTarget( name: "MiniPlayer", - dependencies: ["SwiftBricks"], - resources: [ - .process("Resources") - ] + dependencies: [] ) ] ) diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index 04ecd91..09a412e 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -1,5 +1,4 @@ import SwiftUI -import SwiftBricks import AVFoundation @main @@ -27,6 +26,11 @@ struct MiniPlayerApp: App { Button("全屏") { bridge.toggleFullscreen() } .keyboardShortcut("f", modifiers: .command) Divider() + Button("音量+") { bridge.adjustVolume(by: 0.1) } + .keyboardShortcut("=", modifiers: .command) + Button("音量-") { bridge.adjustVolume(by: -0.1) } + .keyboardShortcut("-", modifiers: .command) + Divider() Button("循环模式") { bridge.cycleRepeatMode() } .keyboardShortcut("r", modifiers: .command) } @@ -42,22 +46,28 @@ struct MiniPlayerApp: App { } } -// MARK: - 主内容视图(直接渲染视频,不走BricksView) +// MARK: - 主内容视图(直接SwiftUI,不经过BricksView) struct ContentView: View { @ObservedObject var bridge: PlayerBridge var body: some View { ZStack { - // 直接渲染视频(跳过BricksView,避免VBox高度压缩问题) + // 视频占满全部 VideoPlayerRepresentable(player: bridge.player) .background(Color.black) .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() + + // 单击播放/暂停 + 双击全屏 + Color.clear .contentShape(Rectangle()) .onTapGesture { bridge.togglePlayPause() } + #if os(macOS) .onTapGesture(count: 2) { bridge.toggleFullscreen() } + #endif + .ignoresSafeArea() - // Logo 图标(左上角,点击显示/隐藏Toolbar) + // Logo(左上角,始终显示) VStack { HStack { Button(action: { bridge.showToolbar.toggle() }) { @@ -75,7 +85,7 @@ struct ContentView: View { Spacer() } - // 底部 Toolbar(半透明,初始隐藏) + // 底部 Toolbar(半透明,点击logo才显示) if bridge.showToolbar { VStack { Spacer() @@ -100,12 +110,16 @@ struct ContentView: View { .sheet(isPresented: $bridge.showURLDialog) { URLInputDialog(bridge: bridge) } - #if os(macOS) .sheet(isPresented: $bridge.showTrackDialog) { TrackSelectDialog(bridge: bridge) } - #endif + .sheet(isPresented: $bridge.showPlaylistSheet) { + PlaylistWindowView(bridge: bridge) + } .animation(.easeInOut(duration: 0.25), value: bridge.showToolbar) + .onReceive(NotificationCenter.default.publisher(for: .init("ExitFullscreen"))) { _ in + bridge.toggleFullscreen() + } } } @@ -120,26 +134,61 @@ struct ControlToolbar: View { Text(bridge.currentTimeText) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.white) - .frame(width: 44, alignment: .trailing) + .frame(width: 50, alignment: .trailing) - ProgressSliderWidget(bridge: bridge, schema: dummySchema, engine: bridge.engine!) + ProgressSlider(player: bridge.player, bridge: bridge) .frame(height: 20) Text(bridge.totalTimeText) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.white) - .frame(width: 44, alignment: .leading) + .frame(width: 50, alignment: .leading) } // 按钮行 HStack(spacing: 10) { + #if os(macOS) ToolbarButton(label: "📂 打开") { bridge.openFileDialog() } + #endif ToolbarButton(label: "⏮") { bridge.playPrev() } ToolbarButton(label: bridge.isPlaying ? "⏸" : "▶️") { bridge.togglePlayPause() } ToolbarButton(label: "⏭") { bridge.playNext() } Spacer() + // 音量控制 + ToolbarButton(label: "🔈") { bridge.adjustVolume(by: -0.1) } + + // 音量条 + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.white.opacity(0.2)) + .frame(height: 4) + RoundedRectangle(cornerRadius: 2) + .fill(Color.accentColor) + .frame(width: geo.size.width * CGFloat(bridge.volume), height: 4) + } + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let ratio = max(0, min(1, value.location.x / geo.size.width)) + bridge.setVolume(Float(ratio)) + } + ) + } + .frame(width: 80, height: 20) + + Text("\(Int(bridge.volume * 100))") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .frame(width: 30) + + ToolbarButton(label: "🔊") { bridge.adjustVolume(by: 0.1) } + + Spacer() + Text(bridge.currentTrackLabel) .font(.system(size: 11)) .foregroundColor(.white.opacity(0.7)) @@ -154,10 +203,6 @@ struct ControlToolbar: View { .padding(.vertical, 8) .background(.black.opacity(0.55)) } - - private var dummySchema: ControlSchema { - ControlSchema(id: "progress_slider", widgettype: "ProgressSlider", options: ControlOptions(), binds: nil, subwidgets: nil) - } } // MARK: - 半透明按钮 @@ -187,28 +232,20 @@ struct PlaylistWindowView: View { var body: some View { VStack(spacing: 0) { - // 顶部操作栏 HStack { Button("📂 添加文件") { bridge.openFileDialog() } Button("🔗 添加URL") { bridge.showURLDialog = true } Spacer() Text("\(bridge.queue.count) 项") - .font(.caption) - .foregroundColor(.secondary) + .font(.caption).foregroundColor(.secondary) } .padding(12) .background(.regularMaterial) Divider() - // 列表 if bridge.queue.isEmpty { - VStack { - Spacer() - Text("暂无媒体,请添加文件或URL") - .foregroundColor(.secondary) - Spacer() - } + VStack { Spacer(); Text("暂无媒体").foregroundColor(.secondary); Spacer() } } else { ScrollView { LazyVStack(spacing: 2) { @@ -218,29 +255,18 @@ struct PlaylistWindowView: View { Text("▶").foregroundColor(.accentColor).font(.caption) } VStack(alignment: .leading, spacing: 2) { - Text(item.name) - .font(.system(size: 13)) - .lineLimit(1) - Text(item.mediaType) - .font(.caption) - .foregroundColor(.secondary) + Text(item.name).font(.system(size: 13)).lineLimit(1) + Text(item.mediaType).font(.caption).foregroundColor(.secondary) } Spacer() - Button(role: .destructive) { - bridge.removeItem(at: idx) - } label: { - Image(systemName: "trash") - .font(.caption) - } - .buttonStyle(.plain) + Button(role: .destructive) { bridge.removeItem(at: idx) } label: { + Image(systemName: "trash").font(.caption) + }.buttonStyle(.plain) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(idx == bridge.currentIndex ? Color.accentColor.opacity(0.15) : Color.clear) + .padding(.horizontal, 12).padding(.vertical, 8) + .background(idx == bridge.currentIndex ? Color.accentColor.opacity(0.15) : .clear) .contentShape(Rectangle()) - .onTapGesture { - bridge.playIndex(idx) - } + .onTapGesture { bridge.playIndex(idx) } } } } @@ -265,10 +291,7 @@ struct URLInputDialog: View { Button("取消") { dismiss() } Spacer() Button("添加") { - if !url.isEmpty { - bridge.addURL(url) - dismiss() - } + if !url.isEmpty { bridge.addURL(url); dismiss() } } .keyboardShortcut(.defaultAction) .disabled(url.isEmpty) @@ -288,10 +311,7 @@ struct TrackSelectDialog: View { VStack(spacing: 12) { Text("选择音轨").font(.headline) ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in - Button(action: { - bridge.selectTrack(index: idx) - dismiss() - }) { + Button(action: { bridge.selectTrack(index: idx); dismiss() }) { HStack { Text("Track \(idx + 1)") Spacer() @@ -299,10 +319,8 @@ struct TrackSelectDialog: View { Text("✓").foregroundColor(.green) } } - .padding(8) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + .padding(8).contentShape(Rectangle()) + }.buttonStyle(.plain) } Divider() Button("关闭") { dismiss() } diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 235fa2f..550e934 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -1,15 +1,16 @@ import SwiftUI import AVFoundation import Combine -import SwiftBricks +#if os(macOS) +import AppKit +#endif /// 播放队列项 struct MediaItem: Identifiable, Equatable { let id: String var url: URL var name: String - var mediaType: String // video/audio/stream - + var mediaType: String static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id } } @@ -36,15 +37,10 @@ enum RepeatMode: String, CaseIterable { } } -/// 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? @@ -56,150 +52,42 @@ final class PlayerBridge: ObservableObject { @Published var currentTimeText = "00:00" @Published var totalTimeText = "00:00" @Published var currentTrackLabel = "🎵 Track 1" - - // 播放列表窗口 - private var playlistWindow: NSWindow? + @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() - 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 cachedDuration: Double = 0 - - // 用户偏好音轨(跨歌曲保持) private var preferredTrackIndex: Int = 0 + #if os(macOS) + private var fullscreenWindow: NSWindow? + private var playlistWindow: NSWindow? + #endif + // MARK: - 初始化 - func setup() { - let eng = BricksEngine() + player.volume = volume - // 注册自定义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() - - // 监听播放状态 setupPlaybackStatusObserver() - } - - // 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) - } - } + setupEndObserver() } // MARK: - 播放控制 - func togglePlayPause() { if player.timeControlStatus == .playing { player.pause() + isPlaying = false } else { player.play() + isPlaying = true } } @@ -213,15 +101,11 @@ final class PlayerBridge: ObservableObject { guard !queue.isEmpty else { return } switch repeatMode { case .none: - if currentIndex < queue.count - 1 { - playIndex(currentIndex + 1) - } + if currentIndex < queue.count - 1 { playIndex(currentIndex + 1) } case .single: - player.seek(to: .zero) - player.play() + player.seek(to: .zero); player.play() case .all: - let idx = (currentIndex + 1) % queue.count - playIndex(idx) + playIndex((currentIndex + 1) % queue.count) } } @@ -233,92 +117,62 @@ final class PlayerBridge: ObservableObject { let playerItem = AVPlayerItem(url: item.url) player.replaceCurrentItem(with: playerItem) player.play() + isPlaying = true - // 重置缓存时长 cachedDuration = 0 + progressRatio = 0 + currentTimeText = "00:00" + totalTimeText = "00:00" - // 监听item状态,加载时长 - itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] item, _ in - if item.status == .readyToPlay { - Task { @MainActor in - self?.loadDuration(item) - } + itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in + if pi.status == .readyToPlay { + Task { @MainActor in self?.loadDuration(pi) } } } - // 加载音轨信息 loadTrackInfo(playerItem) - - // 更新UI - updatePlayButton(isPlaying: true) - updatePlaylistHighlight() - showToast("正在播放: \(item.name)") } - // MARK: - 播放结束处理 - + // MARK: - 播放结束 private func setupEndObserver() { NotificationCenter.default.addObserver( - forName: .AVPlayerItemDidPlayToEndTime, - object: nil, - queue: .main + forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in - self?.onPlaybackEnded() - } + Task { @MainActor in self?.onPlaybackEnded() } } } private func onPlaybackEnded() { switch repeatMode { case .none: - if currentIndex < queue.count - 1 { - playNext() - } else { - updatePlayButton(isPlaying: false) - } + if currentIndex < queue.count - 1 { playNext() } else { isPlaying = false } case .single: - player.seek(to: .zero) - player.play() + 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) - } + 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 + var total = player.currentItem?.duration.seconds ?? 0 - // 如果duration还没加载好,尝试缓存值 - if !total.isFinite || total <= 0 { - total = cachedDuration - } else { - cachedDuration = total - } + if !total.isFinite || total <= 0 { total = cachedDuration } + else { cachedDuration = total } + currentTimeText = formatTime(current) 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)") - currentTimeText = formatTime(current) totalTimeText = formatTime(total) - } else { - eng.store.setValue(id: "time_current", value: formatTime(current)) - currentTimeText = formatTime(current) + progressRatio = max(0, min(1, current / total)) } } @@ -328,7 +182,6 @@ final class PlayerBridge: ObservableObject { let secs = dur.seconds if secs.isFinite && secs > 0 { cachedDuration = secs - engine?.store.setValue(id: "time_total", value: formatTime(secs)) totalTimeText = formatTime(secs) } } @@ -337,13 +190,25 @@ final class PlayerBridge: ObservableObject { private func formatTime(_ seconds: Double) -> String { guard seconds.isFinite && seconds >= 0 else { return "00:00" } - let m = Int(seconds) / 60 + 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: - 音轨 + // 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 { @@ -352,64 +217,70 @@ final class PlayerBridge: ObservableObject { updateTrackLabel() return } - let options = group.options availableTracks = options.enumerated().map { idx, _ in "Track \(idx + 1)" } - - // 使用用户偏好音轨(如果存在),否则用默认 var targetIndex = preferredTrackIndex - if targetIndex >= options.count { - targetIndex = 0 - } - - // 应用选中的音轨 + 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 // 记住用户偏好 + preferredTrackIndex = index updateTrackLabel() } } private func updateTrackLabel() { - let label = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))" - engine?.store.setValue(id: "track_label", value: label) - currentTrackLabel = label + currentTrackLabel = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))" } // 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) - guard let window = NSApp.keyWindow ?? NSApp.windows.first else { return } - window.toggleFullScreen(nil) + 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() @@ -419,115 +290,35 @@ final class PlayerBridge: ObservableObject { 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 + 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 playing: Bool) { - let label = playing ? "⏸" : "▶️" - engine?.store.setValue(id: "btn_play", value: label) - isPlaying = playing - } - - private func updatePlaylistHighlight() { - // 高亮当前播放项(简化实现) - } - - // MARK: - 播放状态监听 - - private func setupPlaybackStatusObserver() { - player.publisher(for: \.timeControlStatus) - .receive(on: DispatchQueue.main) - .sink { [weak self] status in - guard let self = self else { return } - self.isPlaying = (status == .playing) - } - .store(in: &cancellables) - } - - // MARK: - 播放列表窗口 - - 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() - - let hostView = NSHostingView(rootView: PlaylistWindowView(bridge: self)) - win.contentView = hostView - win.makeKeyAndOrderFront(nil) - playlistWindow = win - #endif + 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" + currentTimeText = "00:00"; totalTimeText = "00:00"; progressRatio = 0 } else { currentIndex = min(index, queue.count - 1) playIndex(currentIndex) @@ -535,24 +326,98 @@ final class PlayerBridge: ObservableObject { } else if index < currentIndex { currentIndex -= 1 } - updatePlaylist() + } + + // 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) { - if self.toastMessage == message { - self.toastMessage = nil + 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() } } } } - deinit { - if let observer = timeObserver { - player.removeTimeObserver(observer) + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { // Escape + NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil) } - itemStatusObserver?.invalidate() } } +#endif diff --git a/Sources/ProgressSlider.swift b/Sources/ProgressSlider.swift index 8b06299..8c4ef7a 100644 --- a/Sources/ProgressSlider.swift +++ b/Sources/ProgressSlider.swift @@ -1,35 +1,27 @@ import SwiftUI import AVFoundation -import SwiftBricks -/// ProgressSlider自定义Widget — 播放进度条+拖动seek -struct ProgressSliderWidget: View { - let bridge: PlayerBridge - let schema: ControlSchema - @ObservedObject var engine: BricksEngine - - @State private var progress: Double = 0 - @State private var duration: Double = 0 - @State private var isDragging: Bool = false +/// ProgressSlider — 纯SwiftUI进度条,拖动seek +struct ProgressSlider: View { + let player: AVPlayer + @ObservedObject var bridge: PlayerBridge + @State private var isDragging = false var body: some View { GeometryReader { geo in ZStack(alignment: .leading) { - // 背景轨道 RoundedRectangle(cornerRadius: 2) .fill(Color.secondary.opacity(0.3)) .frame(height: 4) - // 已播放进度 RoundedRectangle(cornerRadius: 2) .fill(Color.accentColor) - .frame(width: geo.size.width * progressRatio, height: 4) + .frame(width: geo.size.width * bridge.progressRatio, height: 4) - // 拖动滑块 Circle() .fill(Color.accentColor) .frame(width: 14, height: 14) - .offset(x: geo.size.width * progressRatio - 7) + .offset(x: geo.size.width * bridge.progressRatio - 7) .shadow(radius: 2) } .frame(height: 20) @@ -39,9 +31,9 @@ struct ProgressSliderWidget: View { .onChanged { value in isDragging = true let ratio = max(0, min(1, value.location.x / geo.size.width)) - if duration > 0 { - let seekTime = ratio * duration - bridge.player.seek(to: CMTime(seconds: seekTime, preferredTimescale: 600)) + if bridge.cachedDuration > 0 { + let seekTime = ratio * bridge.cachedDuration + player.seek(to: CMTime(seconds: seekTime, preferredTimescale: 600)) } } .onEnded { _ in @@ -50,23 +42,5 @@ struct ProgressSliderWidget: View { ) } .frame(height: 20) - .onReceive(engine.store.$values) { values in - if let val = values["progress_slider"] as? String { - let parts = val.split(separator: "/") - if parts.count == 2, - let cur = Double(parts[0]), - let total = Double(parts[1]) { - if !isDragging { - progress = cur - duration = total - } - } - } - } - } - - private var progressRatio: Double { - guard duration > 0 else { return 0 } - return max(0, min(1, progress / duration)) } } diff --git a/Sources/VideoPlayerView.swift b/Sources/VideoPlayerView.swift index cd99e66..ea0b14e 100644 --- a/Sources/VideoPlayerView.swift +++ b/Sources/VideoPlayerView.swift @@ -1,26 +1,5 @@ import SwiftUI import AVFoundation -import SwiftBricks - -/// VideoPlayer自定义Widget — 注册到BricksEngine的"VideoPlayer"类型 -struct VideoPlayerWidget: View { - let bridge: PlayerBridge - let schema: ControlSchema - @ObservedObject var engine: BricksEngine - - var body: some View { - VideoPlayerRepresentable(player: bridge.player) - .background(Color.black) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) - .onTapGesture { - bridge.togglePlayPause() - } - .onTapGesture(count: 2) { - bridge.toggleFullscreen() - } - } -} // MARK: - 跨平台AVPlayer渲染