feat: add VideoPlayer widget with shared AVPlayer via Store

- New VideoPlayerControl.swift wrapping AVKit VideoPlayer
- Prefers shared AVPlayer from Store (e.g. PlayerBridge injection)
- Falls back to options.url for standalone use
- Schema.swift: add autoplay option for VideoPlayer
- ControlRenderer: register VideoPlayer widget type
- Add .gitignore to exclude .build/
This commit is contained in:
yumoqing 2026-06-22 09:52:27 +08:00
parent c72a018764
commit 7bc06097b0
4 changed files with 204 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.build/

View File

@ -0,0 +1,195 @@
import SwiftUI
import AVKit
/// VideoPlayer widget
/// 使 AVKit SwiftBricks
///
/// JSON Schema :
/// ```json
/// {
/// "widgettype": "VideoPlayer",
/// "options": {
/// "url": "https://example.com/video.mp4",
/// "autoplay": true,
/// "controls": true
/// }
/// }
/// ```
///
/// :
/// - play:
/// - pause:
/// - toggle: /
/// - seek: (: time)
/// - setVolume: (: volume)
struct VideoPlayerControl: View {
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var player: AVPlayer?
@State private var isPlaying: Bool = false
var body: some View {
Group {
#if os(macOS)
macOSPlayerView
#else
iOSPlayerView
#endif
}
.onAppear {
setupPlayer()
setupEventListeners()
}
.onDisappear {
player?.pause()
}
}
// MARK: - Platform-specific views
#if os(macOS)
private var macOSPlayerView: some View {
VideoPlayerView(player: player)
.frame(
width: resolveWidth(schema.options),
height: resolveHeight(schema.options)
)
}
private struct VideoPlayerView: NSViewRepresentable {
let player: AVPlayer?
func makeNSView(context: Context) -> NSView {
let view = AVPlayerView()
view.player = player
view.controlsStyle = .default
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
if let playerView = nsView as? AVPlayerView {
playerView.player = player
}
}
}
#else
private var iOSPlayerView: some View {
VideoPlayer(player: player)
.frame(
width: resolveWidth(schema.options),
height: resolveHeight(schema.options)
)
}
#endif
// MARK: - Setup
private func setupPlayer() {
// Store AVPlayer PlayerBridge
let widgetId = schema.effectiveId
if let sharedPlayer = engine.store.getValue(id: "\(widgetId).player") as? AVPlayer {
player = sharedPlayer
isPlaying = sharedPlayer.rate > 0
return
}
// Fallback: schema options url player
guard let urlString = schema.options.url else { return }
if let url = URL(string: urlString) {
player = AVPlayer(url: url)
if schema.options.autoplay == true {
player?.play()
isPlaying = true
}
}
}
private func setupEventListeners() {
let widgetId = schema.effectiveId
//
engine.eventBus.on("\(widgetId).play") { [weak engine] data in
self.player?.play()
self.isPlaying = true
await engine?.eventBus.dispatch("\(widgetId).playing", data: EventData())
}
//
engine.eventBus.on("\(widgetId).pause") { [weak engine] data in
self.player?.pause()
self.isPlaying = false
await engine?.eventBus.dispatch("\(widgetId).paused", data: EventData())
}
//
engine.eventBus.on("\(widgetId).toggle") { [weak engine] data in
if self.isPlaying {
self.player?.pause()
self.isPlaying = false
await engine?.eventBus.dispatch("\(widgetId).paused", data: EventData())
} else {
self.player?.play()
self.isPlaying = true
await engine?.eventBus.dispatch("\(widgetId).playing", data: EventData())
}
}
//
engine.eventBus.on("\(widgetId).seek") { data in
if let timeStr = data["time"] as? String, let time = Double(timeStr) {
self.player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
} else if let time = data["time"] as? Double {
self.player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
}
}
//
engine.eventBus.on("\(widgetId).setVolume") { data in
if let volStr = data["volume"] as? String, let vol = Float(volStr) {
self.player?.volume = vol
} else if let vol = data["volume"] as? Float {
self.player?.volume = vol
}
}
//
engine.eventBus.on("\(widgetId).setURL") { data in
if let urlString = data["url"] as? String, let url = URL(string: urlString) {
let wasPlaying = self.isPlaying
self.player?.pause()
self.player = AVPlayer(url: url)
if wasPlaying {
self.player?.play()
}
}
}
}
// MARK: - Helpers
private func resolveWidth(_ opts: ControlOptions) -> CGFloat? {
if let w = opts.width {
if w == "100%" { return nil }
if let px = parsePixels(w) { return px }
}
return nil
}
private func resolveHeight(_ opts: ControlOptions) -> CGFloat? {
if let h = opts.height {
if h == "100%" { return nil }
if let px = parsePixels(h) { return px }
}
return nil
}
private func parsePixels(_ str: String) -> CGFloat? {
let cleaned = str.replacingOccurrences(of: "px", with: "")
return CGFloat(Double(cleaned) ?? 0)
}
}

View File

@ -129,6 +129,10 @@ public struct ControlOptions: Codable, Sendable {
public var src: String?
public var alt: String?
// VideoPlayer
public var autoplay: Bool?
public var controls: Bool?
// Misc
public var fontsize: String?
public var cfontsize: Double?

View File

@ -108,6 +108,10 @@ public struct ControlRenderer: View {
// Image
case "Image":
ImageControl(schema: schema, engine: engine)
// VideoPlayer
case "VideoPlayer":
VideoPlayerControl(schema: schema, engine: engine)
// urlwidget
case "urlwidget":