feat: SwiftBricks UI integration with shared AVPlayer
- Inject PlayerBridge AVPlayer into BricksStore for VideoPlayer widget - Load miniplayer.json from SPM bundle resources - Fix infinite render loop: pass JSON string instead of schema object - Fix async closure mismatch in setupBridgeEvents - Enhanced JSON: URL input, load/play/pause buttons, title bar - Remove duplicate Resources/miniplayer.json (keep SPM-compliant path)
This commit is contained in:
parent
9db72b49aa
commit
bdf834012a
@ -8,14 +8,15 @@ let package = Package(
|
||||
.iOS(.v17),
|
||||
.macOS(.v14)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "MiniPlayer", targets: ["MiniPlayer"])
|
||||
dependencies: [
|
||||
.package(path: "../swiftbricks")
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "MiniPlayer",
|
||||
dependencies: [],
|
||||
dependencies: [
|
||||
.product(name: "SwiftBricks", package: "swiftbricks")
|
||||
],
|
||||
resources: [.process("Resources")]
|
||||
)
|
||||
]
|
||||
|
||||
124
Resources/miniplayer.json
Normal file
124
Resources/miniplayer.json
Normal file
@ -0,0 +1,124 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"height": "100%",
|
||||
"bgcolor": "#000000"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "VideoPlayer",
|
||||
"id": "videoPlayer",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"height": "85%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"height": "15%",
|
||||
"bgcolor": "#1a1a1a",
|
||||
"spacing": 10,
|
||||
"padding": "10px"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "playBtn",
|
||||
"options": {
|
||||
"label": "Play",
|
||||
"icon": "play.fill",
|
||||
"cwidth": 8,
|
||||
"cheight": 4,
|
||||
"bgcolor": "#007aff",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "event",
|
||||
"target": "videoPlayer.play"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "pauseBtn",
|
||||
"options": {
|
||||
"label": "Pause",
|
||||
"icon": "pause.fill",
|
||||
"cwidth": 8,
|
||||
"cheight": 4,
|
||||
"bgcolor": "#ff9500",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "event",
|
||||
"target": "videoPlayer.pause"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "toggleBtn",
|
||||
"options": {
|
||||
"label": "Toggle",
|
||||
"icon": "playpause.fill",
|
||||
"cwidth": 10,
|
||||
"cheight": 4,
|
||||
"bgcolor": "#34c759",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "event",
|
||||
"target": "videoPlayer.toggle"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "Filler"
|
||||
},
|
||||
{
|
||||
"widgettype": "Input",
|
||||
"id": "videoUrlInput",
|
||||
"options": {
|
||||
"name": "url",
|
||||
"placeholder": "Enter video URL",
|
||||
"cwidth": 30,
|
||||
"cheight": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "loadBtn",
|
||||
"options": {
|
||||
"label": "Load",
|
||||
"icon": "arrow.down.circle.fill",
|
||||
"cwidth": 8,
|
||||
"cheight": 4,
|
||||
"bgcolor": "#5856d6",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "method",
|
||||
"target": "videoPlayer",
|
||||
"method": "setURL"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,16 +1,21 @@
|
||||
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 {
|
||||
ContentView(bridge: bridge)
|
||||
BricksAppView(bridge: bridge, engine: engine)
|
||||
.frame(minWidth: 900, minHeight: 600)
|
||||
.onAppear { bridge.setup() }
|
||||
.onAppear {
|
||||
bridge.setup()
|
||||
setupBridgeEvents()
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.commands {
|
||||
@ -45,79 +50,81 @@ struct MiniPlayerApp: App {
|
||||
}
|
||||
#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: - 主内容视图
|
||||
struct ContentView: View {
|
||||
// MARK: - BricksView wrapper
|
||||
struct BricksAppView: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
// 自动隐藏:本地状态,不经过 bridge 的 @Published
|
||||
@State private var toolbarVisible = false
|
||||
@State private var isHoveringToolbar = false
|
||||
@State private var lastInteraction = Date()
|
||||
private let hideTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
|
||||
private let autoHideInterval: TimeInterval = 60
|
||||
@ObservedObject var engine: BricksEngine
|
||||
@State private var jsonString: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 视频占满全部
|
||||
VideoPlayerRepresentable(player: bridge.player)
|
||||
.background(Color.black)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// 单击播放/暂停 + 双击全屏
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
bridge.togglePlayPause()
|
||||
touchInteraction()
|
||||
}
|
||||
#if os(macOS)
|
||||
.onTapGesture(count: 2) { bridge.toggleFullscreen() }
|
||||
#endif
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Logo(左上角,始终显示)
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: {
|
||||
toolbarVisible.toggle()
|
||||
if toolbarVisible { touchInteraction() }
|
||||
}) {
|
||||
MiniPlayerIcon()
|
||||
.frame(width: 36, height: 36)
|
||||
.padding(6)
|
||||
.background(.black.opacity(0.4))
|
||||
.cornerRadius(10)
|
||||
if let json = jsonString {
|
||||
BricksView(json: json, engine: engine)
|
||||
} else {
|
||||
ProgressView("Loading UI...")
|
||||
.task {
|
||||
jsonString = loadJSONString()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.top, 12)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// 底部 Toolbar(半透明)
|
||||
if toolbarVisible {
|
||||
VStack {
|
||||
Spacer()
|
||||
ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar, onTouch: touchInteraction)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
// Toast
|
||||
if let msg = bridge.toastMessage {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(msg)
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.7))
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $bridge.showURLDialog) {
|
||||
@ -129,23 +136,24 @@ struct ContentView: View {
|
||||
.sheet(isPresented: $bridge.showPlaylistSheet) {
|
||||
PlaylistWindowView(bridge: bridge)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: toolbarVisible)
|
||||
// 自动隐藏 Timer:每2秒检查一次
|
||||
.onReceive(hideTimer) { _ in
|
||||
guard toolbarVisible else { return }
|
||||
if isHoveringToolbar { lastInteraction = Date(); return }
|
||||
if Date().timeIntervalSince(lastInteraction) >= autoHideInterval {
|
||||
toolbarVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func touchInteraction() {
|
||||
lastInteraction = Date()
|
||||
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
|
||||
|
||||
164
Sources/Resources/miniplayer.json
Normal file
164
Sources/Resources/miniplayer.json
Normal file
@ -0,0 +1,164 @@
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"height": "100%",
|
||||
"bgcolor": "#000000"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "VideoPlayer",
|
||||
"id": "videoPlayer",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"height": "85%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"options": {
|
||||
"width": "100%",
|
||||
"height": "15%",
|
||||
"bgcolor": "#1a1a1a",
|
||||
"spacing": 6,
|
||||
"padding": "8px 12px"
|
||||
},
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "openBtn",
|
||||
"options": {
|
||||
"label": "Open",
|
||||
"icon": "folder",
|
||||
"cwidth": 8,
|
||||
"cheight": 4,
|
||||
"bgcolor": "#555555",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "event",
|
||||
"target": "videoPlayer.openFile"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "prevBtn",
|
||||
"options": {
|
||||
"label": "Prev",
|
||||
"icon": "backward.fill",
|
||||
"cwidth": 8,
|
||||
"cheight": 4,
|
||||
"bgcolor": "#333333",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "event",
|
||||
"target": "videoPlayer.prev"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "toggleBtn",
|
||||
"options": {
|
||||
"label": "Play/Pause",
|
||||
"icon": "playpause.fill",
|
||||
"cwidth": 12,
|
||||
"cheight": 4,
|
||||
"bgcolor": "#007aff",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "event",
|
||||
"target": "videoPlayer.toggle"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "nextBtn",
|
||||
"options": {
|
||||
"label": "Next",
|
||||
"icon": "forward.fill",
|
||||
"cwidth": 8,
|
||||
"cheight": 4,
|
||||
"bgcolor": "#333333",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "event",
|
||||
"target": "videoPlayer.next"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "Filler"
|
||||
},
|
||||
{
|
||||
"widgettype": "Input",
|
||||
"id": "videoUrlInput",
|
||||
"options": {
|
||||
"name": "url",
|
||||
"placeholder": "Enter video URL...",
|
||||
"cwidth": 30,
|
||||
"cheight": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "loadBtn",
|
||||
"options": {
|
||||
"label": "Load",
|
||||
"icon": "arrow.down.circle.fill",
|
||||
"cwidth": 8,
|
||||
"cheight": 4,
|
||||
"bgcolor": "#5856d6",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "method",
|
||||
"target": "videoPlayer",
|
||||
"method": "setURL"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button",
|
||||
"id": "fullscreenBtn",
|
||||
"options": {
|
||||
"label": "Fullscreen",
|
||||
"icon": "arrow.up.left.and.arrow.down.right",
|
||||
"cwidth": 10,
|
||||
"cheight": 4,
|
||||
"bgcolor": "#ff9500",
|
||||
"color": "#ffffff"
|
||||
},
|
||||
"binds": [
|
||||
{
|
||||
"wid": "self",
|
||||
"event": "click",
|
||||
"actiontype": "event",
|
||||
"target": "videoPlayer.fullscreen"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user