370 lines
14 KiB
Swift
370 lines
14 KiB
Swift
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.ui
|
||
if let url = Bundle.module.url(forResource: "miniplayer", withExtension: "ui"),
|
||
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)
|
||
}
|
||
}
|