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),
|
.iOS(.v17),
|
||||||
.macOS(.v14)
|
.macOS(.v14)
|
||||||
],
|
],
|
||||||
products: [
|
dependencies: [
|
||||||
.executable(name: "MiniPlayer", targets: ["MiniPlayer"])
|
.package(path: "../swiftbricks")
|
||||||
],
|
],
|
||||||
dependencies: [],
|
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "MiniPlayer",
|
name: "MiniPlayer",
|
||||||
dependencies: [],
|
dependencies: [
|
||||||
|
.product(name: "SwiftBricks", package: "swiftbricks")
|
||||||
|
],
|
||||||
resources: [.process("Resources")]
|
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 SwiftUI
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Combine
|
import Combine
|
||||||
|
import SwiftBricks
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct MiniPlayerApp: App {
|
struct MiniPlayerApp: App {
|
||||||
@StateObject private var bridge = PlayerBridge()
|
@StateObject private var bridge = PlayerBridge()
|
||||||
|
@StateObject private var engine = BricksEngine()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView(bridge: bridge)
|
BricksAppView(bridge: bridge, engine: engine)
|
||||||
.frame(minWidth: 900, minHeight: 600)
|
.frame(minWidth: 900, minHeight: 600)
|
||||||
.onAppear { bridge.setup() }
|
.onAppear {
|
||||||
|
bridge.setup()
|
||||||
|
setupBridgeEvents()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.commands {
|
.commands {
|
||||||
@ -45,79 +50,81 @@ struct MiniPlayerApp: App {
|
|||||||
}
|
}
|
||||||
#endif
|
#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: - 主内容视图
|
// MARK: - BricksView wrapper
|
||||||
struct ContentView: View {
|
struct BricksAppView: View {
|
||||||
@ObservedObject var bridge: PlayerBridge
|
@ObservedObject var bridge: PlayerBridge
|
||||||
// 自动隐藏:本地状态,不经过 bridge 的 @Published
|
@ObservedObject var engine: BricksEngine
|
||||||
@State private var toolbarVisible = false
|
@State private var jsonString: String?
|
||||||
@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
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// 视频占满全部
|
if let json = jsonString {
|
||||||
VideoPlayerRepresentable(player: bridge.player)
|
BricksView(json: json, engine: engine)
|
||||||
.background(Color.black)
|
} else {
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
ProgressView("Loading UI...")
|
||||||
.ignoresSafeArea()
|
.task {
|
||||||
|
jsonString = loadJSONString()
|
||||||
// 单击播放/暂停 + 双击全屏
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
.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) {
|
.sheet(isPresented: $bridge.showURLDialog) {
|
||||||
@ -129,23 +136,24 @@ struct ContentView: View {
|
|||||||
.sheet(isPresented: $bridge.showPlaylistSheet) {
|
.sheet(isPresented: $bridge.showPlaylistSheet) {
|
||||||
PlaylistWindowView(bridge: bridge)
|
PlaylistWindowView(bridge: bridge)
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.25), value: toolbarVisible)
|
}
|
||||||
// 自动隐藏 Timer:每2秒检查一次
|
|
||||||
.onReceive(hideTimer) { _ in
|
private func loadJSONString() -> String {
|
||||||
guard toolbarVisible else { return }
|
// 从 Resources 加载 miniplayer.json
|
||||||
if isHoveringToolbar { lastInteraction = Date(); return }
|
if let url = Bundle.module.url(forResource: "miniplayer", withExtension: "json"),
|
||||||
if Date().timeIntervalSince(lastInteraction) >= autoHideInterval {
|
let data = try? Data(contentsOf: url),
|
||||||
toolbarVisible = false
|
let str = String(data: data, encoding: .utf8) {
|
||||||
}
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
// Fallback: 最小化 schema
|
||||||
|
return """
|
||||||
private func touchInteraction() {
|
{"widgettype":"VBox","options":{"width":"100%","height":"100%"},"subwidgets":[{"widgettype":"Text","options":{"text":"Failed to load UI schema"}}]}
|
||||||
lastInteraction = Date()
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - 底部控制栏
|
// MARK: - 底部控制栏
|
||||||
struct ControlToolbar: View {
|
struct ControlToolbar: View {
|
||||||
@ObservedObject var bridge: PlayerBridge
|
@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