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:
parent
c72a018764
commit
7bc06097b0
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.build/
|
||||
195
Sources/SwiftBricks/Controls/VideoPlayerControl.swift
Normal file
195
Sources/SwiftBricks/Controls/VideoPlayerControl.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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":
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user