import SwiftUI import AVFoundation import Combine import SwiftBricks @main struct MiniPlayerApp: App { @StateObject private var bridge = PlayerBridge() @StateObject private var engine = BricksEngine() var body: some Scene { WindowGroup { BricksAppView(bridge: bridge, engine: engine) .frame(minWidth: 900, minHeight: 600) .onAppear { bridge.setup() setupBridgeEvents() } } #if os(macOS) .commands { CommandGroup(replacing: .newItem) {} CommandMenu(L.playback) { Button(L.playPause) { bridge.togglePlayPause() } .keyboardShortcut(" ", modifiers: []) Divider() Button(L.prev) { bridge.playPrev() } .keyboardShortcut("[", modifiers: []) Button(L.next) { bridge.playNext() } .keyboardShortcut("]", modifiers: []) Divider() Button(L.fullscreen) { bridge.toggleFullscreen() } .keyboardShortcut("f", modifiers: .command) Divider() Button(L.volUp) { bridge.adjustVolume(by: 0.1) } .keyboardShortcut("=", modifiers: .command) Button(L.volDown) { bridge.adjustVolume(by: -0.1) } .keyboardShortcut("-", modifiers: .command) Divider() Button(L.repeatMode) { bridge.cycleRepeatMode() } .keyboardShortcut("r", modifiers: .command) } CommandMenu(L.file) { Button(L.openFile) { bridge.openFileDialog() } .keyboardShortcut("o", modifiers: .command) Divider() Button(L.addURL) { bridge.showURLDialog = true } .keyboardShortcut("u", modifiers: .command) } } #endif } // 桥接 BricksEngine 事件到 PlayerBridge private func setupBridgeEvents() { // 将 PlayerBridge 的 AVPlayer 注入 Store,让 VideoPlayerControl 共享 engine.store.setValue(id: "videoPlayer.player", value: bridge.player) // 播放控制 — 从 Store 读取 Input 值或事件数据 engine.eventBus.on("videoPlayer.setURL") { [weak bridge, weak engine] data in guard let bridge, let engine else { return } // 优先从事件数据取 URL,否则从 Input widget 的 store 值取 var url: String? = data["url"] as? String if url == nil || url?.isEmpty == true { url = engine.store.getValue(id: "videoUrlInput") as? String } if let url, !url.isEmpty { bridge.addURL(url) } } // seek — 直接操作共享 AVPlayer engine.eventBus.on("videoPlayer.seek") { [weak bridge] data in guard let bridge else { return } if let timeStr = data["time"] as? String, let time = Double(timeStr) { bridge.player.seek(to: CMTime(seconds: time, preferredTimescale: 600)) } } // 音量 engine.eventBus.on("videoPlayer.setVolume") { [weak bridge] data in guard let bridge else { return } if let volStr = data["volume"] as? String, let vol = Float(volStr) { bridge.setVolume(vol) } } // 全屏 engine.eventBus.on("videoPlayer.fullscreen") { [weak bridge] data in guard let bridge else { return } bridge.toggleFullscreen() } // 上一曲/下一曲 engine.eventBus.on("videoPlayer.prev") { [weak bridge] data in guard let bridge else { return } bridge.playPrev() } engine.eventBus.on("videoPlayer.next") { [weak bridge] data in guard let bridge else { return } bridge.playNext() } // 打开文件 engine.eventBus.on("videoPlayer.openFile") { [weak bridge] data in guard let bridge else { return } bridge.openFileDialog() } } } // MARK: - BricksView wrapper struct BricksAppView: View { @ObservedObject var bridge: PlayerBridge @ObservedObject var engine: BricksEngine @State private var jsonString: String? var body: some View { ZStack { if let json = jsonString { BricksView(json: json, engine: engine) } else { ProgressView("Loading UI...") .task { jsonString = loadJSONString() } } } .sheet(isPresented: $bridge.showURLDialog) { URLInputDialog(bridge: bridge) } .sheet(isPresented: $bridge.showTrackDialog) { TrackSelectDialog(bridge: bridge) } .sheet(isPresented: $bridge.showPlaylistSheet) { PlaylistWindowView(bridge: bridge) } } private func loadJSONString() -> String { // 从 Resources 加载 miniplayer.json if let url = Bundle.module.url(forResource: "miniplayer", withExtension: "json"), let data = try? Data(contentsOf: url), let str = String(data: data, encoding: .utf8) { return str } // Fallback: 最小化 schema return """ {"widgettype":"VBox","options":{"width":"100%","height":"100%"},"subwidgets":[{"widgettype":"Text","options":{"text":"Failed to load UI schema"}}]} """ } } // MARK: - 底部控制栏 struct ControlToolbar: View { @ObservedObject var bridge: PlayerBridge @Binding var isHovering: Bool let onTouch: () -> Void var body: some View { VStack(spacing: 6) { // 进度条行 HStack(spacing: 8) { Text(bridge.currentTimeText) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.white) .frame(width: 50, alignment: .trailing) ProgressSlider(player: bridge.player, bridge: bridge) .frame(height: 20) Text(bridge.totalTimeText) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.white) .frame(width: 50, alignment: .leading) } // 按钮行 HStack(spacing: 10) { #if os(macOS) ToolbarButton(label: "📂 \(L.open)") { bridge.openFileDialog(); onTouch() } #endif ToolbarButton(label: "⏮") { bridge.playPrev(); onTouch() } ToolbarButton(label: bridge.isPlaying ? "⏸" : "▶️") { bridge.togglePlayPause(); onTouch() } ToolbarButton(label: "⏭") { bridge.playNext(); onTouch() } Spacer() // 音量控制 ToolbarButton(label: "🔈") { bridge.adjustVolume(by: -0.1); onTouch() } // 音量条 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)) onTouch() } ) } .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); onTouch() } Spacer() Text(bridge.currentTrackLabel) .font(.system(size: 11)) .foregroundColor(.white.opacity(0.7)) ToolbarButton(label: L.audioTrack) { bridge.showTrackDialog = true; onTouch() } ToolbarButton(label: "\(bridge.repeatMode.icon) \(bridge.repeatMode.displayName)") { bridge.cycleRepeatMode(); onTouch() } ToolbarButton(label: "⛶") { bridge.toggleFullscreen(); onTouch() } ToolbarButton(label: "📋 \(L.list) (\(bridge.queue.count))") { bridge.togglePlaylistWindow(); onTouch() } } } .padding(.horizontal, 16) .padding(.vertical, 8) .background(.black.opacity(0.55)) .onHover { hovering in isHovering = hovering if hovering { onTouch() } } } } // MARK: - 半透明按钮 struct ToolbarButton: View { let label: String let action: () -> Void @State private var hovering = false var body: some View { Button(action: action) { Text(label) .font(.system(size: 13)) .foregroundColor(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(hovering ? .white.opacity(0.15) : .clear) .cornerRadius(4) } .buttonStyle(.plain) .onHover { hovering = $0 } } } // MARK: - 播放列表弹窗 struct PlaylistWindowView: View { @ObservedObject var bridge: PlayerBridge var body: some View { VStack(spacing: 0) { HStack { Button(L.addFile) { bridge.openFileDialog() } Button(L.addURLBtn) { bridge.showURLDialog = true } Spacer() Text("\(bridge.queue.count) \(L.itemsCount)") .font(.caption).foregroundColor(.secondary) } .padding(12) .background(.regularMaterial) Divider() if bridge.queue.isEmpty { VStack { Spacer(); Text(L.noMedia).foregroundColor(.secondary); Spacer() } } else { ScrollView { LazyVStack(spacing: 2) { ForEach(Array(bridge.queue.enumerated()), id: \.element.id) { idx, item in HStack { if idx == bridge.currentIndex { 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) } Spacer() 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) : .clear) .contentShape(Rectangle()) .onTapGesture { bridge.playIndex(idx) } } } } } } .frame(minWidth: 350, minHeight: 400) } } // MARK: - URL输入弹窗 struct URLInputDialog: View { @ObservedObject var bridge: PlayerBridge @State private var url = "" @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 16) { Text(L.addMediaURL).font(.headline) TextField("https://example.com/video.m3u8", text: $url) .textFieldStyle(.roundedBorder) HStack { Button(L.cancel) { dismiss() } Spacer() Button(L.add) { if !url.isEmpty { bridge.addURL(url); dismiss() } } .keyboardShortcut(.defaultAction) .disabled(url.isEmpty) } } .padding(20) .frame(width: 400) } } // MARK: - 音轨选择弹窗 struct TrackSelectDialog: View { @ObservedObject var bridge: PlayerBridge @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 12) { Text(L.selectTrack).font(.headline) ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in Button(action: { bridge.selectTrack(index: idx); dismiss() }) { HStack { Text("\(L.track) \(idx + 1)") Spacer() if idx == bridge.currentTrackIndex { Text("✓").foregroundColor(.green) } } .padding(8).contentShape(Rectangle()) }.buttonStyle(.plain) } Divider() Button(L.close) { dismiss() } } .padding(20) .frame(minWidth: 250) } }