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 src: String?
|
||||||
public var alt: String?
|
public var alt: String?
|
||||||
|
|
||||||
|
// VideoPlayer
|
||||||
|
public var autoplay: Bool?
|
||||||
|
public var controls: Bool?
|
||||||
|
|
||||||
// Misc
|
// Misc
|
||||||
public var fontsize: String?
|
public var fontsize: String?
|
||||||
public var cfontsize: Double?
|
public var cfontsize: Double?
|
||||||
|
|||||||
@ -108,6 +108,10 @@ public struct ControlRenderer: View {
|
|||||||
// Image
|
// Image
|
||||||
case "Image":
|
case "Image":
|
||||||
ImageControl(schema: schema, engine: engine)
|
ImageControl(schema: schema, engine: engine)
|
||||||
|
|
||||||
|
// VideoPlayer
|
||||||
|
case "VideoPlayer":
|
||||||
|
VideoPlayerControl(schema: schema, engine: engine)
|
||||||
|
|
||||||
// urlwidget(直接加载远程内容)
|
// urlwidget(直接加载远程内容)
|
||||||
case "urlwidget":
|
case "urlwidget":
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user