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:
yumoqing 2026-06-22 09:52:28 +08:00
parent 9db72b49aa
commit bdf834012a
4 changed files with 381 additions and 84 deletions

View File

@ -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
View 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"
}
]
}
]
}
]
}

View File

@ -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)
// Timer2
.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

View 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"
}
]
}
]
}
]
}