MiniPlayer/Sources/PlayerBridge.swift

440 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 AVPlayerBricksEngine
///
@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) {
// Bundleplayer.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 // iOSURL
#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)
}
}
}