440 lines
14 KiB
Swift
440 lines
14 KiB
Swift
import SwiftUI
|
||
import AVFoundation
|
||
import Combine
|
||
import SwiftBricks
|
||
|
||
/// 播放队列项
|
||
struct MediaItem: Identifiable, Equatable {
|
||
let id: String
|
||
var url: URL
|
||
var name: String
|
||
var mediaType: String // video/audio/stream
|
||
|
||
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
|
||
}
|
||
|
||
/// 循环模式
|
||
enum RepeatMode: String, CaseIterable {
|
||
case none = "不循环"
|
||
case single = "单曲循环"
|
||
case all = "列表循环"
|
||
|
||
var icon: String {
|
||
switch self {
|
||
case .none: return "➡️"
|
||
case .single: return "🔂"
|
||
case .all: return "🔁"
|
||
}
|
||
}
|
||
|
||
var next: RepeatMode {
|
||
switch self {
|
||
case .none: return .single
|
||
case .single: return .all
|
||
case .all: return .none
|
||
}
|
||
}
|
||
}
|
||
|
||
/// PlayerBridge — 连接AVPlayer与BricksEngine
|
||
/// 管理播放队列、音轨、全屏、循环模式
|
||
@MainActor
|
||
final class PlayerBridge: ObservableObject {
|
||
|
||
// MARK: - Published状态
|
||
|
||
@Published var engine: BricksEngine?
|
||
@Published var schema: ControlSchema?
|
||
@Published var showURLDialog = false
|
||
@Published var showTrackDialog = false
|
||
@Published var toastMessage: String?
|
||
@Published var availableTracks: [String] = []
|
||
@Published var currentTrackIndex: Int = 0
|
||
@Published var isFullscreen = false
|
||
|
||
// MARK: - 内部状态
|
||
|
||
let player = AVPlayer()
|
||
var queue: [MediaItem] = []
|
||
var currentIndex: Int = -1
|
||
var repeatMode: RepeatMode = .all
|
||
|
||
private var timeObserver: Any?
|
||
private var endObserver: Any?
|
||
private var cancellables = Set<AnyCancellable>()
|
||
|
||
// MARK: - 初始化
|
||
|
||
func setup() {
|
||
let eng = BricksEngine()
|
||
|
||
// 注册自定义widget
|
||
eng.registerWidget(type: "VideoPlayer") { [weak self] schema, engine in
|
||
AnyView(VideoPlayerWidget(bridge: self!, schema: schema, engine: engine))
|
||
}
|
||
eng.registerWidget(type: "ProgressSlider") { [weak self] schema, engine in
|
||
AnyView(ProgressSliderWidget(bridge: self!, schema: schema, engine: engine))
|
||
}
|
||
|
||
engine = eng
|
||
|
||
// 加载player.ui
|
||
loadPlayerUI(engine: eng)
|
||
|
||
// 注册事件监听
|
||
registerEvents(engine: eng)
|
||
|
||
// 设置播放结束监听
|
||
setupEndObserver()
|
||
|
||
// 设置时间监听
|
||
setupTimeObserver()
|
||
}
|
||
|
||
// MARK: - 加载UI定义文件
|
||
|
||
private func loadPlayerUI(engine: BricksEngine) {
|
||
// 从Bundle加载player.ui
|
||
let bundle = Bundle.module
|
||
|
||
if let url = bundle.url(forResource: "player", withExtension: "ui"),
|
||
let data = try? Data(contentsOf: url),
|
||
let json = String(data: data, encoding: .utf8) {
|
||
do {
|
||
try engine.loadJSON(json)
|
||
schema = engine.rootSchema
|
||
} catch {
|
||
showToast("JSON加载失败: \(error.localizedDescription)")
|
||
}
|
||
} else {
|
||
// Fallback: 内嵌JSON
|
||
let fallback = """
|
||
{"id":"app","widgettype":"VBox","options":{"width":"100%","height":"100%"},"subwidgets":[
|
||
{"widgettype":"VideoPlayer","id":"video_player","options":{"width":"100%","bgcolor":"#000"}},
|
||
{"widgettype":"HBox","options":{"spacing":8,"alignItems":"center","padding":"8px"},
|
||
"subwidgets":[
|
||
{"widgettype":"Button","id":"btn_prev","options":{"label":"⏮","css":"text"},
|
||
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.prev"}]},
|
||
{"widgettype":"Button","id":"btn_play","options":{"label":"▶️","css":"text"},
|
||
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.toggle"}]},
|
||
{"widgettype":"Button","id":"btn_next","options":{"label":"⏭","css":"text"},
|
||
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.next"}]},
|
||
{"widgettype":"Filler"},
|
||
{"widgettype":"Button","id":"btn_repeat","options":{"label":"🔁","css":"text"},
|
||
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.cycle_repeat"}]},
|
||
{"widgettype":"Button","id":"btn_fullscreen","options":{"label":"⛶","css":"text"},
|
||
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.fullscreen"}]}
|
||
]},
|
||
{"widgettype":"Text","id":"playlist_info","options":{"text":"播放列表: 空"}}
|
||
]}
|
||
"""
|
||
do {
|
||
try engine.loadJSON(fallback)
|
||
schema = engine.rootSchema
|
||
} catch {
|
||
showToast("Fallback JSON失败: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 事件注册
|
||
|
||
private func registerEvents(engine: BricksEngine) {
|
||
let bus = engine.eventBus
|
||
|
||
bus.on("player.toggle") { [weak self] _ in
|
||
self?.togglePlayPause()
|
||
}
|
||
bus.on("player.prev") { [weak self] _ in
|
||
self?.playPrev()
|
||
}
|
||
bus.on("player.next") { [weak self] _ in
|
||
self?.playNext()
|
||
}
|
||
bus.on("player.cycle_repeat") { [weak self] _ in
|
||
self?.cycleRepeatMode()
|
||
}
|
||
bus.on("player.fullscreen") { [weak self] _ in
|
||
self?.toggleFullscreen()
|
||
}
|
||
bus.on("player.show_tracks") { [weak self] _ in
|
||
self?.showTrackDialog = true
|
||
}
|
||
bus.on("player.add_url") { [weak self] data in
|
||
if let url = data["url"] as? String, !url.isEmpty {
|
||
self?.addURL(url)
|
||
}
|
||
}
|
||
bus.on("player.open_file") { [weak self] _ in
|
||
self?.openFileDialog()
|
||
}
|
||
bus.on("player.play_selected") { [weak self] data in
|
||
if let index = data["index"] as? Int {
|
||
self?.playIndex(index)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 播放控制
|
||
|
||
func togglePlayPause() {
|
||
if player.timeControlStatus == .playing {
|
||
player.pause()
|
||
} else {
|
||
player.play()
|
||
}
|
||
}
|
||
|
||
func playPrev() {
|
||
guard !queue.isEmpty else { return }
|
||
let idx = (currentIndex - 1 + queue.count) % queue.count
|
||
playIndex(idx)
|
||
}
|
||
|
||
func playNext() {
|
||
guard !queue.isEmpty else { return }
|
||
switch repeatMode {
|
||
case .none:
|
||
if currentIndex < queue.count - 1 {
|
||
playIndex(currentIndex + 1)
|
||
}
|
||
case .single:
|
||
player.seek(to: .zero)
|
||
player.play()
|
||
case .all:
|
||
let idx = (currentIndex + 1) % queue.count
|
||
playIndex(idx)
|
||
}
|
||
}
|
||
|
||
func playIndex(_ index: Int) {
|
||
guard index >= 0 && index < queue.count else { return }
|
||
currentIndex = index
|
||
let item = queue[index]
|
||
|
||
let playerItem = AVPlayerItem(url: item.url)
|
||
player.replaceCurrentItem(with: playerItem)
|
||
player.play()
|
||
|
||
// 加载音轨信息
|
||
loadTrackInfo(playerItem)
|
||
|
||
// 更新UI
|
||
updatePlayButton(isPlaying: true)
|
||
updatePlaylistHighlight()
|
||
|
||
showToast("正在播放: \(item.name)")
|
||
}
|
||
|
||
// MARK: - 播放结束处理
|
||
|
||
private func setupEndObserver() {
|
||
NotificationCenter.default.addObserver(
|
||
forName: .AVPlayerItemDidPlayToEndTime,
|
||
object: nil,
|
||
queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor in
|
||
self?.onPlaybackEnded()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func onPlaybackEnded() {
|
||
switch repeatMode {
|
||
case .none:
|
||
if currentIndex < queue.count - 1 {
|
||
playNext()
|
||
} else {
|
||
updatePlayButton(isPlaying: false)
|
||
}
|
||
case .single:
|
||
player.seek(to: .zero)
|
||
player.play()
|
||
case .all:
|
||
playNext()
|
||
}
|
||
}
|
||
|
||
// MARK: - 时间更新
|
||
|
||
private func setupTimeObserver() {
|
||
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
|
||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||
Task { @MainActor in
|
||
self?.updateTimeDisplay(time: time)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func updateTimeDisplay(time: CMTime) {
|
||
guard let eng = engine, let item = player.currentItem else { return }
|
||
|
||
let current = time.seconds
|
||
let total = item.duration.seconds
|
||
|
||
if total.isFinite && total > 0 {
|
||
eng.store.setValue(id: "time_current", value: formatTime(current))
|
||
eng.store.setValue(id: "time_total", value: formatTime(total))
|
||
eng.store.setValue(id: "progress_slider", value: "\(current)/\(total)")
|
||
}
|
||
}
|
||
|
||
private func formatTime(_ seconds: Double) -> String {
|
||
guard seconds.isFinite && seconds >= 0 else { return "00:00" }
|
||
let m = Int(seconds) / 60
|
||
let s = Int(seconds) % 60
|
||
return String(format: "%02d:%02d", m, s)
|
||
}
|
||
|
||
// MARK: - 音轨
|
||
|
||
private func loadTrackInfo(_ item: AVPlayerItem) {
|
||
Task {
|
||
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible) else {
|
||
availableTracks = ["Track 1"]
|
||
currentTrackIndex = 0
|
||
updateTrackLabel()
|
||
return
|
||
}
|
||
|
||
let options = group.options
|
||
availableTracks = options.enumerated().map { idx, _ in "Track \(idx + 1)" }
|
||
currentTrackIndex = 0
|
||
|
||
// 选择默认音轨
|
||
if let defaultOpt = group.defaultOption,
|
||
let idx = options.firstIndex(of: defaultOpt) {
|
||
currentTrackIndex = idx
|
||
}
|
||
|
||
updateTrackLabel()
|
||
}
|
||
}
|
||
|
||
func selectTrack(index: Int) {
|
||
guard let item = player.currentItem else { return }
|
||
|
||
Task {
|
||
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible),
|
||
index < group.options.count else { return }
|
||
|
||
item.select(group.options[index], in: group)
|
||
currentTrackIndex = index
|
||
updateTrackLabel()
|
||
}
|
||
}
|
||
|
||
private func updateTrackLabel() {
|
||
let label = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||
engine?.store.setValue(id: "track_label", value: label)
|
||
}
|
||
|
||
// MARK: - 循环模式
|
||
|
||
func cycleRepeatMode() {
|
||
repeatMode = repeatMode.next
|
||
let label = "\(repeatMode.icon) \(repeatMode.rawValue)"
|
||
engine?.store.setValue(id: "btn_repeat", value: label)
|
||
showToast("循环模式: \(repeatMode.rawValue)")
|
||
}
|
||
|
||
// MARK: - 全屏
|
||
|
||
func toggleFullscreen() {
|
||
isFullscreen.toggle()
|
||
#if os(macOS)
|
||
if let window = NSApp.keyWindow {
|
||
window.toggleFullScreen(nil)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// MARK: - 文件操作
|
||
|
||
func openFileDialog() {
|
||
#if os(macOS)
|
||
let panel = NSOpenPanel()
|
||
panel.canChooseFiles = true
|
||
panel.canChooseDirectories = false
|
||
panel.allowsMultipleSelection = true
|
||
panel.allowedContentTypes = [
|
||
.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .avi, .mp3, .wav, .mpeg4Audio
|
||
]
|
||
|
||
if panel.runModal() == .OK {
|
||
for url in panel.urls {
|
||
addItem(url: url, name: url.lastPathComponent, type: "file")
|
||
}
|
||
}
|
||
#elseif os(iOS)
|
||
showURLDialog = true // iOS用URL输入代替文件选择
|
||
#endif
|
||
}
|
||
|
||
func addURL(_ urlString: String) {
|
||
guard let url = URL(string: urlString) else {
|
||
showToast("无效URL")
|
||
return
|
||
}
|
||
let name = url.lastPathComponent.isEmpty ? url.host ?? urlString : url.lastPathComponent
|
||
let type = urlString.contains(".m3u8") ? "stream" : "url"
|
||
addItem(url: url, name: name, type: type)
|
||
}
|
||
|
||
private func addItem(url: URL, name: String, type: String) {
|
||
let item = MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type)
|
||
queue.append(item)
|
||
updatePlaylist()
|
||
|
||
// 如果队列为空或只有一个,自动播放
|
||
if queue.count == 1 {
|
||
playIndex(0)
|
||
}
|
||
}
|
||
|
||
// MARK: - 播放列表UI更新
|
||
|
||
private func updatePlaylist() {
|
||
guard let eng = engine else { return }
|
||
|
||
// 更新playlist_panel的内容
|
||
if queue.isEmpty {
|
||
eng.store.setValue(id: "playlist_empty", value: "暂无媒体,请添加文件或URL")
|
||
} else {
|
||
let list = queue.enumerated().map { idx, item in
|
||
"\(idx + 1). \(item.name) [\(item.mediaType)]"
|
||
}.joined(separator: "\n")
|
||
eng.store.setValue(id: "playlist_empty", value: list)
|
||
}
|
||
|
||
eng.store.setValue(id: "playlist_info", value: "播放列表: \(queue.count) 项")
|
||
}
|
||
|
||
private func updatePlayButton(isPlaying: Bool) {
|
||
let label = isPlaying ? "⏸" : "▶️"
|
||
engine?.store.setValue(id: "btn_play", value: label)
|
||
}
|
||
|
||
private func updatePlaylistHighlight() {
|
||
// 高亮当前播放项(简化实现)
|
||
}
|
||
|
||
// MARK: - Toast
|
||
|
||
private func showToast(_ message: String) {
|
||
toastMessage = message
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||
if self.toastMessage == message {
|
||
self.toastMessage = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
if let observer = timeObserver {
|
||
player.removeTimeObserver(observer)
|
||
}
|
||
}
|
||
}
|