fix: rewrite UI as pure SwiftUI, fix crash/fullscreen/height issues
- Skip BricksView, render video directly in SwiftUI (fixes 1/3 height) - Fullscreen uses plain NSView+AVPlayerLayer (fixes objc_release crash) - Remove NSApp.hide(nil) (fixes fullscreen not showing) - Add volume +/- buttons and volume slider indicator - Add iOS/iPadOS support with #if os guards - ProgressSlider decoupled from BricksEngine - PlayerBridge no longer depends on player.ui JSON
This commit is contained in:
parent
e4ca9bc80a
commit
6811572b7e
@ -10,16 +10,11 @@ let package = Package(
|
|||||||
products: [
|
products: [
|
||||||
.executable(name: "MiniPlayer", targets: ["MiniPlayer"])
|
.executable(name: "MiniPlayer", targets: ["MiniPlayer"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [],
|
||||||
.package(path: "../SwiftBricks")
|
|
||||||
],
|
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "MiniPlayer",
|
name: "MiniPlayer",
|
||||||
dependencies: ["SwiftBricks"],
|
dependencies: []
|
||||||
resources: [
|
|
||||||
.process("Resources")
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftBricks
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@ -27,6 +26,11 @@ struct MiniPlayerApp: App {
|
|||||||
Button("全屏") { bridge.toggleFullscreen() }
|
Button("全屏") { bridge.toggleFullscreen() }
|
||||||
.keyboardShortcut("f", modifiers: .command)
|
.keyboardShortcut("f", modifiers: .command)
|
||||||
Divider()
|
Divider()
|
||||||
|
Button("音量+") { bridge.adjustVolume(by: 0.1) }
|
||||||
|
.keyboardShortcut("=", modifiers: .command)
|
||||||
|
Button("音量-") { bridge.adjustVolume(by: -0.1) }
|
||||||
|
.keyboardShortcut("-", modifiers: .command)
|
||||||
|
Divider()
|
||||||
Button("循环模式") { bridge.cycleRepeatMode() }
|
Button("循环模式") { bridge.cycleRepeatMode() }
|
||||||
.keyboardShortcut("r", modifiers: .command)
|
.keyboardShortcut("r", modifiers: .command)
|
||||||
}
|
}
|
||||||
@ -42,22 +46,28 @@ struct MiniPlayerApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 主内容视图(直接渲染视频,不走BricksView)
|
// MARK: - 主内容视图(直接SwiftUI,不经过BricksView)
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@ObservedObject var bridge: PlayerBridge
|
@ObservedObject var bridge: PlayerBridge
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// 直接渲染视频(跳过BricksView,避免VBox高度压缩问题)
|
// 视频占满全部
|
||||||
VideoPlayerRepresentable(player: bridge.player)
|
VideoPlayerRepresentable(player: bridge.player)
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// 单击播放/暂停 + 双击全屏
|
||||||
|
Color.clear
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { bridge.togglePlayPause() }
|
.onTapGesture { bridge.togglePlayPause() }
|
||||||
|
#if os(macOS)
|
||||||
.onTapGesture(count: 2) { bridge.toggleFullscreen() }
|
.onTapGesture(count: 2) { bridge.toggleFullscreen() }
|
||||||
|
#endif
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
// Logo 图标(左上角,点击显示/隐藏Toolbar)
|
// Logo(左上角,始终显示)
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: { bridge.showToolbar.toggle() }) {
|
Button(action: { bridge.showToolbar.toggle() }) {
|
||||||
@ -75,7 +85,7 @@ struct ContentView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 底部 Toolbar(半透明,初始隐藏)
|
// 底部 Toolbar(半透明,点击logo才显示)
|
||||||
if bridge.showToolbar {
|
if bridge.showToolbar {
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -100,12 +110,16 @@ struct ContentView: View {
|
|||||||
.sheet(isPresented: $bridge.showURLDialog) {
|
.sheet(isPresented: $bridge.showURLDialog) {
|
||||||
URLInputDialog(bridge: bridge)
|
URLInputDialog(bridge: bridge)
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
|
||||||
.sheet(isPresented: $bridge.showTrackDialog) {
|
.sheet(isPresented: $bridge.showTrackDialog) {
|
||||||
TrackSelectDialog(bridge: bridge)
|
TrackSelectDialog(bridge: bridge)
|
||||||
}
|
}
|
||||||
#endif
|
.sheet(isPresented: $bridge.showPlaylistSheet) {
|
||||||
|
PlaylistWindowView(bridge: bridge)
|
||||||
|
}
|
||||||
.animation(.easeInOut(duration: 0.25), value: bridge.showToolbar)
|
.animation(.easeInOut(duration: 0.25), value: bridge.showToolbar)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .init("ExitFullscreen"))) { _ in
|
||||||
|
bridge.toggleFullscreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,26 +134,61 @@ struct ControlToolbar: View {
|
|||||||
Text(bridge.currentTimeText)
|
Text(bridge.currentTimeText)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(width: 44, alignment: .trailing)
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
|
||||||
ProgressSliderWidget(bridge: bridge, schema: dummySchema, engine: bridge.engine!)
|
ProgressSlider(player: bridge.player, bridge: bridge)
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
|
|
||||||
Text(bridge.totalTimeText)
|
Text(bridge.totalTimeText)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(width: 44, alignment: .leading)
|
.frame(width: 50, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按钮行
|
// 按钮行
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
|
#if os(macOS)
|
||||||
ToolbarButton(label: "📂 打开") { bridge.openFileDialog() }
|
ToolbarButton(label: "📂 打开") { bridge.openFileDialog() }
|
||||||
|
#endif
|
||||||
ToolbarButton(label: "⏮") { bridge.playPrev() }
|
ToolbarButton(label: "⏮") { bridge.playPrev() }
|
||||||
ToolbarButton(label: bridge.isPlaying ? "⏸" : "▶️") { bridge.togglePlayPause() }
|
ToolbarButton(label: bridge.isPlaying ? "⏸" : "▶️") { bridge.togglePlayPause() }
|
||||||
ToolbarButton(label: "⏭") { bridge.playNext() }
|
ToolbarButton(label: "⏭") { bridge.playNext() }
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// 音量控制
|
||||||
|
ToolbarButton(label: "🔈") { bridge.adjustVolume(by: -0.1) }
|
||||||
|
|
||||||
|
// 音量条
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.white.opacity(0.2))
|
||||||
|
.frame(height: 4)
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
.frame(width: geo.size.width * CGFloat(bridge.volume), height: 4)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.onChanged { value in
|
||||||
|
let ratio = max(0, min(1, value.location.x / geo.size.width))
|
||||||
|
bridge.setVolume(Float(ratio))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(width: 80, height: 20)
|
||||||
|
|
||||||
|
Text("\(Int(bridge.volume * 100))")
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.frame(width: 30)
|
||||||
|
|
||||||
|
ToolbarButton(label: "🔊") { bridge.adjustVolume(by: 0.1) }
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
Text(bridge.currentTrackLabel)
|
Text(bridge.currentTrackLabel)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.white.opacity(0.7))
|
.foregroundColor(.white.opacity(0.7))
|
||||||
@ -154,10 +203,6 @@ struct ControlToolbar: View {
|
|||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(.black.opacity(0.55))
|
.background(.black.opacity(0.55))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dummySchema: ControlSchema {
|
|
||||||
ControlSchema(id: "progress_slider", widgettype: "ProgressSlider", options: ControlOptions(), binds: nil, subwidgets: nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 半透明按钮
|
// MARK: - 半透明按钮
|
||||||
@ -187,28 +232,20 @@ struct PlaylistWindowView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// 顶部操作栏
|
|
||||||
HStack {
|
HStack {
|
||||||
Button("📂 添加文件") { bridge.openFileDialog() }
|
Button("📂 添加文件") { bridge.openFileDialog() }
|
||||||
Button("🔗 添加URL") { bridge.showURLDialog = true }
|
Button("🔗 添加URL") { bridge.showURLDialog = true }
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(bridge.queue.count) 项")
|
Text("\(bridge.queue.count) 项")
|
||||||
.font(.caption)
|
.font(.caption).foregroundColor(.secondary)
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(.regularMaterial)
|
.background(.regularMaterial)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// 列表
|
|
||||||
if bridge.queue.isEmpty {
|
if bridge.queue.isEmpty {
|
||||||
VStack {
|
VStack { Spacer(); Text("暂无媒体").foregroundColor(.secondary); Spacer() }
|
||||||
Spacer()
|
|
||||||
Text("暂无媒体,请添加文件或URL")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 2) {
|
LazyVStack(spacing: 2) {
|
||||||
@ -218,29 +255,18 @@ struct PlaylistWindowView: View {
|
|||||||
Text("▶").foregroundColor(.accentColor).font(.caption)
|
Text("▶").foregroundColor(.accentColor).font(.caption)
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(item.name)
|
Text(item.name).font(.system(size: 13)).lineLimit(1)
|
||||||
.font(.system(size: 13))
|
Text(item.mediaType).font(.caption).foregroundColor(.secondary)
|
||||||
.lineLimit(1)
|
|
||||||
Text(item.mediaType)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) { bridge.removeItem(at: idx) } label: {
|
||||||
bridge.removeItem(at: idx)
|
Image(systemName: "trash").font(.caption)
|
||||||
} label: {
|
}.buttonStyle(.plain)
|
||||||
Image(systemName: "trash")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||||
.padding(.vertical, 8)
|
.background(idx == bridge.currentIndex ? Color.accentColor.opacity(0.15) : .clear)
|
||||||
.background(idx == bridge.currentIndex ? Color.accentColor.opacity(0.15) : Color.clear)
|
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture { bridge.playIndex(idx) }
|
||||||
bridge.playIndex(idx)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -265,10 +291,7 @@ struct URLInputDialog: View {
|
|||||||
Button("取消") { dismiss() }
|
Button("取消") { dismiss() }
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("添加") {
|
Button("添加") {
|
||||||
if !url.isEmpty {
|
if !url.isEmpty { bridge.addURL(url); dismiss() }
|
||||||
bridge.addURL(url)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
.disabled(url.isEmpty)
|
.disabled(url.isEmpty)
|
||||||
@ -288,10 +311,7 @@ struct TrackSelectDialog: View {
|
|||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("选择音轨").font(.headline)
|
Text("选择音轨").font(.headline)
|
||||||
ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in
|
ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in
|
||||||
Button(action: {
|
Button(action: { bridge.selectTrack(index: idx); dismiss() }) {
|
||||||
bridge.selectTrack(index: idx)
|
|
||||||
dismiss()
|
|
||||||
}) {
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("Track \(idx + 1)")
|
Text("Track \(idx + 1)")
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -299,10 +319,8 @@ struct TrackSelectDialog: View {
|
|||||||
Text("✓").foregroundColor(.green)
|
Text("✓").foregroundColor(.green)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8).contentShape(Rectangle())
|
||||||
.contentShape(Rectangle())
|
}.buttonStyle(.plain)
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
Button("关闭") { dismiss() }
|
Button("关闭") { dismiss() }
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftBricks
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// 播放队列项
|
/// 播放队列项
|
||||||
struct MediaItem: Identifiable, Equatable {
|
struct MediaItem: Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
var url: URL
|
var url: URL
|
||||||
var name: String
|
var name: String
|
||||||
var mediaType: String // video/audio/stream
|
var mediaType: String
|
||||||
|
|
||||||
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
|
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,15 +37,10 @@ enum RepeatMode: String, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PlayerBridge — 连接AVPlayer与BricksEngine
|
|
||||||
/// 管理播放队列、音轨、全屏、循环模式
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PlayerBridge: ObservableObject {
|
final class PlayerBridge: ObservableObject {
|
||||||
|
|
||||||
// MARK: - Published状态
|
// MARK: - Published状态
|
||||||
|
|
||||||
@Published var engine: BricksEngine?
|
|
||||||
@Published var schema: ControlSchema?
|
|
||||||
@Published var showURLDialog = false
|
@Published var showURLDialog = false
|
||||||
@Published var showTrackDialog = false
|
@Published var showTrackDialog = false
|
||||||
@Published var toastMessage: String?
|
@Published var toastMessage: String?
|
||||||
@ -56,150 +52,42 @@ final class PlayerBridge: ObservableObject {
|
|||||||
@Published var currentTimeText = "00:00"
|
@Published var currentTimeText = "00:00"
|
||||||
@Published var totalTimeText = "00:00"
|
@Published var totalTimeText = "00:00"
|
||||||
@Published var currentTrackLabel = "🎵 Track 1"
|
@Published var currentTrackLabel = "🎵 Track 1"
|
||||||
|
@Published var volume: Float = 1.0
|
||||||
// 播放列表窗口
|
@Published var progressRatio: Double = 0
|
||||||
private var playlistWindow: NSWindow?
|
@Published var cachedDuration: Double = 0
|
||||||
|
@Published var queue: [MediaItem] = []
|
||||||
|
@Published var currentIndex: Int = -1
|
||||||
|
@Published var repeatMode: RepeatMode = .all
|
||||||
|
|
||||||
// MARK: - 内部状态
|
// MARK: - 内部状态
|
||||||
|
|
||||||
let player = AVPlayer()
|
let player = AVPlayer()
|
||||||
var queue: [MediaItem] = []
|
|
||||||
var currentIndex: Int = -1
|
|
||||||
var repeatMode: RepeatMode = .all
|
|
||||||
|
|
||||||
private var timeObserver: Any?
|
private var timeObserver: Any?
|
||||||
private var endObserver: Any?
|
|
||||||
private var itemStatusObserver: NSKeyValueObservation?
|
private var itemStatusObserver: NSKeyValueObservation?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
// 缓存时长
|
|
||||||
private var cachedDuration: Double = 0
|
|
||||||
|
|
||||||
// 用户偏好音轨(跨歌曲保持)
|
|
||||||
private var preferredTrackIndex: Int = 0
|
private var preferredTrackIndex: Int = 0
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private var fullscreenWindow: NSWindow?
|
||||||
|
private var playlistWindow: NSWindow?
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - 初始化
|
// MARK: - 初始化
|
||||||
|
|
||||||
func setup() {
|
func setup() {
|
||||||
let eng = BricksEngine()
|
player.volume = volume
|
||||||
|
|
||||||
// 注册自定义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()
|
setupTimeObserver()
|
||||||
|
|
||||||
// 监听播放状态
|
|
||||||
setupPlaybackStatusObserver()
|
setupPlaybackStatusObserver()
|
||||||
}
|
setupEndObserver()
|
||||||
|
|
||||||
// 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: - 播放控制
|
// MARK: - 播放控制
|
||||||
|
|
||||||
func togglePlayPause() {
|
func togglePlayPause() {
|
||||||
if player.timeControlStatus == .playing {
|
if player.timeControlStatus == .playing {
|
||||||
player.pause()
|
player.pause()
|
||||||
|
isPlaying = false
|
||||||
} else {
|
} else {
|
||||||
player.play()
|
player.play()
|
||||||
|
isPlaying = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,15 +101,11 @@ final class PlayerBridge: ObservableObject {
|
|||||||
guard !queue.isEmpty else { return }
|
guard !queue.isEmpty else { return }
|
||||||
switch repeatMode {
|
switch repeatMode {
|
||||||
case .none:
|
case .none:
|
||||||
if currentIndex < queue.count - 1 {
|
if currentIndex < queue.count - 1 { playIndex(currentIndex + 1) }
|
||||||
playIndex(currentIndex + 1)
|
|
||||||
}
|
|
||||||
case .single:
|
case .single:
|
||||||
player.seek(to: .zero)
|
player.seek(to: .zero); player.play()
|
||||||
player.play()
|
|
||||||
case .all:
|
case .all:
|
||||||
let idx = (currentIndex + 1) % queue.count
|
playIndex((currentIndex + 1) % queue.count)
|
||||||
playIndex(idx)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,92 +117,62 @@ final class PlayerBridge: ObservableObject {
|
|||||||
let playerItem = AVPlayerItem(url: item.url)
|
let playerItem = AVPlayerItem(url: item.url)
|
||||||
player.replaceCurrentItem(with: playerItem)
|
player.replaceCurrentItem(with: playerItem)
|
||||||
player.play()
|
player.play()
|
||||||
|
isPlaying = true
|
||||||
|
|
||||||
// 重置缓存时长
|
|
||||||
cachedDuration = 0
|
cachedDuration = 0
|
||||||
|
progressRatio = 0
|
||||||
|
currentTimeText = "00:00"
|
||||||
|
totalTimeText = "00:00"
|
||||||
|
|
||||||
// 监听item状态,加载时长
|
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||||||
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] item, _ in
|
if pi.status == .readyToPlay {
|
||||||
if item.status == .readyToPlay {
|
Task { @MainActor in self?.loadDuration(pi) }
|
||||||
Task { @MainActor in
|
|
||||||
self?.loadDuration(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载音轨信息
|
|
||||||
loadTrackInfo(playerItem)
|
loadTrackInfo(playerItem)
|
||||||
|
|
||||||
// 更新UI
|
|
||||||
updatePlayButton(isPlaying: true)
|
|
||||||
updatePlaylistHighlight()
|
|
||||||
|
|
||||||
showToast("正在播放: \(item.name)")
|
showToast("正在播放: \(item.name)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 播放结束处理
|
// MARK: - 播放结束
|
||||||
|
|
||||||
private func setupEndObserver() {
|
private func setupEndObserver() {
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
forName: .AVPlayerItemDidPlayToEndTime,
|
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in self?.onPlaybackEnded() }
|
||||||
self?.onPlaybackEnded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func onPlaybackEnded() {
|
private func onPlaybackEnded() {
|
||||||
switch repeatMode {
|
switch repeatMode {
|
||||||
case .none:
|
case .none:
|
||||||
if currentIndex < queue.count - 1 {
|
if currentIndex < queue.count - 1 { playNext() } else { isPlaying = false }
|
||||||
playNext()
|
|
||||||
} else {
|
|
||||||
updatePlayButton(isPlaying: false)
|
|
||||||
}
|
|
||||||
case .single:
|
case .single:
|
||||||
player.seek(to: .zero)
|
player.seek(to: .zero); player.play()
|
||||||
player.play()
|
|
||||||
case .all:
|
case .all:
|
||||||
playNext()
|
playNext()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 时间更新
|
// MARK: - 时间更新
|
||||||
|
|
||||||
private func setupTimeObserver() {
|
private func setupTimeObserver() {
|
||||||
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
|
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
|
||||||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||||
Task { @MainActor in
|
Task { @MainActor in self?.updateTimeDisplay(time: time) }
|
||||||
self?.updateTimeDisplay(time: time)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateTimeDisplay(time: CMTime) {
|
private func updateTimeDisplay(time: CMTime) {
|
||||||
guard let eng = engine, let item = player.currentItem else { return }
|
|
||||||
|
|
||||||
let current = time.seconds
|
let current = time.seconds
|
||||||
var total = item.duration.seconds
|
var total = player.currentItem?.duration.seconds ?? 0
|
||||||
|
|
||||||
// 如果duration还没加载好,尝试缓存值
|
if !total.isFinite || total <= 0 { total = cachedDuration }
|
||||||
if !total.isFinite || total <= 0 {
|
else { cachedDuration = total }
|
||||||
total = cachedDuration
|
|
||||||
} else {
|
|
||||||
cachedDuration = total
|
|
||||||
}
|
|
||||||
|
|
||||||
|
currentTimeText = formatTime(current)
|
||||||
if total.isFinite && total > 0 {
|
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)")
|
|
||||||
currentTimeText = formatTime(current)
|
|
||||||
totalTimeText = formatTime(total)
|
totalTimeText = formatTime(total)
|
||||||
} else {
|
progressRatio = max(0, min(1, current / total))
|
||||||
eng.store.setValue(id: "time_current", value: formatTime(current))
|
|
||||||
currentTimeText = formatTime(current)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -328,7 +182,6 @@ final class PlayerBridge: ObservableObject {
|
|||||||
let secs = dur.seconds
|
let secs = dur.seconds
|
||||||
if secs.isFinite && secs > 0 {
|
if secs.isFinite && secs > 0 {
|
||||||
cachedDuration = secs
|
cachedDuration = secs
|
||||||
engine?.store.setValue(id: "time_total", value: formatTime(secs))
|
|
||||||
totalTimeText = formatTime(secs)
|
totalTimeText = formatTime(secs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -337,13 +190,25 @@ final class PlayerBridge: ObservableObject {
|
|||||||
|
|
||||||
private func formatTime(_ seconds: Double) -> String {
|
private func formatTime(_ seconds: Double) -> String {
|
||||||
guard seconds.isFinite && seconds >= 0 else { return "00:00" }
|
guard seconds.isFinite && seconds >= 0 else { return "00:00" }
|
||||||
let m = Int(seconds) / 60
|
let h = Int(seconds) / 3600
|
||||||
|
let m = (Int(seconds) % 3600) / 60
|
||||||
let s = Int(seconds) % 60
|
let s = Int(seconds) % 60
|
||||||
|
if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
|
||||||
return String(format: "%02d:%02d", m, s)
|
return String(format: "%02d:%02d", m, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 音轨
|
// MARK: - 音量
|
||||||
|
func adjustVolume(by delta: Float) {
|
||||||
|
volume = max(0, min(1, volume + delta))
|
||||||
|
player.volume = volume
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVolume(_ val: Float) {
|
||||||
|
volume = max(0, min(1, val))
|
||||||
|
player.volume = volume
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 音轨
|
||||||
private func loadTrackInfo(_ item: AVPlayerItem) {
|
private func loadTrackInfo(_ item: AVPlayerItem) {
|
||||||
Task {
|
Task {
|
||||||
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible) else {
|
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible) else {
|
||||||
@ -352,64 +217,70 @@ final class PlayerBridge: ObservableObject {
|
|||||||
updateTrackLabel()
|
updateTrackLabel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let options = group.options
|
let options = group.options
|
||||||
availableTracks = options.enumerated().map { idx, _ in "Track \(idx + 1)" }
|
availableTracks = options.enumerated().map { idx, _ in "Track \(idx + 1)" }
|
||||||
|
|
||||||
// 使用用户偏好音轨(如果存在),否则用默认
|
|
||||||
var targetIndex = preferredTrackIndex
|
var targetIndex = preferredTrackIndex
|
||||||
if targetIndex >= options.count {
|
if targetIndex >= options.count { targetIndex = 0 }
|
||||||
targetIndex = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用选中的音轨
|
|
||||||
item.select(options[targetIndex], in: group)
|
item.select(options[targetIndex], in: group)
|
||||||
currentTrackIndex = targetIndex
|
currentTrackIndex = targetIndex
|
||||||
|
|
||||||
updateTrackLabel()
|
updateTrackLabel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectTrack(index: Int) {
|
func selectTrack(index: Int) {
|
||||||
guard let item = player.currentItem else { return }
|
guard let item = player.currentItem else { return }
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible),
|
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible),
|
||||||
index < group.options.count else { return }
|
index < group.options.count else { return }
|
||||||
|
|
||||||
item.select(group.options[index], in: group)
|
item.select(group.options[index], in: group)
|
||||||
currentTrackIndex = index
|
currentTrackIndex = index
|
||||||
preferredTrackIndex = index // 记住用户偏好
|
preferredTrackIndex = index
|
||||||
updateTrackLabel()
|
updateTrackLabel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateTrackLabel() {
|
private func updateTrackLabel() {
|
||||||
let label = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
currentTrackLabel = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||||||
engine?.store.setValue(id: "track_label", value: label)
|
|
||||||
currentTrackLabel = label
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 循环模式
|
// MARK: - 循环模式
|
||||||
|
|
||||||
func cycleRepeatMode() {
|
func cycleRepeatMode() {
|
||||||
repeatMode = repeatMode.next
|
repeatMode = repeatMode.next
|
||||||
let label = "\(repeatMode.icon) \(repeatMode.rawValue)"
|
|
||||||
engine?.store.setValue(id: "btn_repeat", value: label)
|
|
||||||
showToast("循环模式: \(repeatMode.rawValue)")
|
showToast("循环模式: \(repeatMode.rawValue)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 全屏(视频内容全屏,非窗口全屏)
|
// MARK: - 全屏(视频内容全屏,非窗口全屏)
|
||||||
|
|
||||||
func toggleFullscreen() {
|
func toggleFullscreen() {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
guard let window = NSApp.keyWindow ?? NSApp.windows.first else { return }
|
if let fw = fullscreenWindow {
|
||||||
window.toggleFullScreen(nil)
|
fw.close()
|
||||||
|
fullscreenWindow = nil
|
||||||
|
isFullscreen = false
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let screen = NSScreen.main else { return }
|
||||||
|
let win = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false)
|
||||||
|
win.level = .screenSaver
|
||||||
|
win.backgroundColor = .black
|
||||||
|
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||||
|
win.hasShadow = false
|
||||||
|
|
||||||
|
// 使用纯 NSView + AVPlayerLayer,避免 SwiftUI 桥接崩溃
|
||||||
|
let playerView = FullscreenPlayerView(frame: screen.frame)
|
||||||
|
playerView.player = player
|
||||||
|
win.contentView = playerView
|
||||||
|
win.makeKeyAndOrderFront(nil)
|
||||||
|
// 确保键盘事件(Escape退出)能被接收
|
||||||
|
win.makeFirstResponder(playerView)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
fullscreenWindow = win
|
||||||
|
isFullscreen = true
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 文件操作
|
// MARK: - 文件操作
|
||||||
|
|
||||||
func openFileDialog() {
|
func openFileDialog() {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
@ -419,115 +290,35 @@ final class PlayerBridge: ObservableObject {
|
|||||||
panel.allowedContentTypes = [
|
panel.allowedContentTypes = [
|
||||||
.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .avi, .mp3, .wav, .mpeg4Audio
|
.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .avi, .mp3, .wav, .mpeg4Audio
|
||||||
]
|
]
|
||||||
|
|
||||||
if panel.runModal() == .OK {
|
if panel.runModal() == .OK {
|
||||||
for url in panel.urls {
|
for url in panel.urls {
|
||||||
addItem(url: url, name: url.lastPathComponent, type: "file")
|
addItem(url: url, name: url.lastPathComponent, type: "file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#elseif os(iOS)
|
|
||||||
showURLDialog = true // iOS用URL输入代替文件选择
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func addURL(_ urlString: String) {
|
func addURL(_ urlString: String) {
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else { showToast("无效URL"); return }
|
||||||
showToast("无效URL")
|
let name = url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent
|
||||||
return
|
|
||||||
}
|
|
||||||
let name = url.lastPathComponent.isEmpty ? url.host ?? urlString : url.lastPathComponent
|
|
||||||
let type = urlString.contains(".m3u8") ? "stream" : "url"
|
let type = urlString.contains(".m3u8") ? "stream" : "url"
|
||||||
addItem(url: url, name: name, type: type)
|
addItem(url: url, name: name, type: type)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addItem(url: URL, name: String, type: String) {
|
private func addItem(url: URL, name: String, type: String) {
|
||||||
let item = MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type)
|
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type))
|
||||||
queue.append(item)
|
if queue.count == 1 { playIndex(0) }
|
||||||
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 playing: Bool) {
|
|
||||||
let label = playing ? "⏸" : "▶️"
|
|
||||||
engine?.store.setValue(id: "btn_play", value: label)
|
|
||||||
isPlaying = playing
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updatePlaylistHighlight() {
|
|
||||||
// 高亮当前播放项(简化实现)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 播放状态监听
|
|
||||||
|
|
||||||
private func setupPlaybackStatusObserver() {
|
|
||||||
player.publisher(for: \.timeControlStatus)
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] status in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.isPlaying = (status == .playing)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 播放列表窗口
|
|
||||||
|
|
||||||
func togglePlaylistWindow() {
|
|
||||||
#if os(macOS)
|
|
||||||
if let win = playlistWindow, win.isVisible {
|
|
||||||
win.close()
|
|
||||||
playlistWindow = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let win = NSWindow(
|
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
|
||||||
styleMask: [.titled, .closable, .resizable],
|
|
||||||
backing: .buffered, defer: false
|
|
||||||
)
|
|
||||||
win.title = "播放列表 (\(queue.count))"
|
|
||||||
win.isReleasedWhenClosed = false
|
|
||||||
win.center()
|
|
||||||
|
|
||||||
let hostView = NSHostingView(rootView: PlaylistWindowView(bridge: self))
|
|
||||||
win.contentView = hostView
|
|
||||||
win.makeKeyAndOrderFront(nil)
|
|
||||||
playlistWindow = win
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeItem(at index: Int) {
|
func removeItem(at index: Int) {
|
||||||
guard index >= 0 && index < queue.count else { return }
|
guard index >= 0 && index < queue.count else { return }
|
||||||
let wasPlaying = (index == currentIndex)
|
let wasPlaying = (index == currentIndex)
|
||||||
queue.remove(at: index)
|
queue.remove(at: index)
|
||||||
|
|
||||||
if wasPlaying {
|
if wasPlaying {
|
||||||
if queue.isEmpty {
|
if queue.isEmpty {
|
||||||
currentIndex = -1
|
currentIndex = -1
|
||||||
player.replaceCurrentItem(with: nil)
|
player.replaceCurrentItem(with: nil)
|
||||||
currentTimeText = "00:00"
|
currentTimeText = "00:00"; totalTimeText = "00:00"; progressRatio = 0
|
||||||
totalTimeText = "00:00"
|
|
||||||
} else {
|
} else {
|
||||||
currentIndex = min(index, queue.count - 1)
|
currentIndex = min(index, queue.count - 1)
|
||||||
playIndex(currentIndex)
|
playIndex(currentIndex)
|
||||||
@ -535,24 +326,98 @@ final class PlayerBridge: ObservableObject {
|
|||||||
} else if index < currentIndex {
|
} else if index < currentIndex {
|
||||||
currentIndex -= 1
|
currentIndex -= 1
|
||||||
}
|
}
|
||||||
updatePlaylist()
|
}
|
||||||
|
|
||||||
|
// MARK: - 播放列表窗口
|
||||||
|
@Published var showPlaylistSheet = false
|
||||||
|
|
||||||
|
func togglePlaylistWindow() {
|
||||||
|
#if os(macOS)
|
||||||
|
if let win = playlistWindow, win.isVisible {
|
||||||
|
win.close(); playlistWindow = nil; return
|
||||||
|
}
|
||||||
|
let win = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
||||||
|
styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false
|
||||||
|
)
|
||||||
|
win.title = "播放列表 (\(queue.count))"
|
||||||
|
win.isReleasedWhenClosed = false
|
||||||
|
win.center()
|
||||||
|
win.contentView = NSHostingView(rootView: PlaylistWindowView(bridge: self))
|
||||||
|
win.makeKeyAndOrderFront(nil)
|
||||||
|
playlistWindow = win
|
||||||
|
#else
|
||||||
|
showPlaylistSheet.toggle()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Toast
|
// MARK: - Toast
|
||||||
|
|
||||||
private func showToast(_ message: String) {
|
private func showToast(_ message: String) {
|
||||||
toastMessage = message
|
toastMessage = message
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
if self.toastMessage == message {
|
if self?.toastMessage == message { self?.toastMessage = nil }
|
||||||
self.toastMessage = nil
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 播放状态
|
||||||
|
private func setupPlaybackStatusObserver() {
|
||||||
|
player.publisher(for: \.timeControlStatus)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] status in
|
||||||
|
self?.isPlaying = (status == .playing)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let observer = timeObserver { player.removeTimeObserver(observer) }
|
||||||
|
itemStatusObserver?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 全屏专用 NSView(纯 AppKit,不经过 SwiftUI)
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
class FullscreenPlayerView: NSView {
|
||||||
|
var player: AVPlayer? {
|
||||||
|
get { (layer as? AVPlayerLayer)?.player }
|
||||||
|
set { (layer as? AVPlayerLayer)?.player = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: NSRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
wantsLayer = true
|
||||||
|
let playerLayer = AVPlayerLayer()
|
||||||
|
playerLayer.videoGravity = .resizeAspect
|
||||||
|
layer = playerLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func layout() {
|
||||||
|
super.layout()
|
||||||
|
(layer as? AVPlayerLayer)?.frame = bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
if event.clickCount == 2 {
|
||||||
|
// 双击退出全屏 — 通过通知
|
||||||
|
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
|
||||||
|
} else {
|
||||||
|
// 单击播放/暂停
|
||||||
|
if let p = player {
|
||||||
|
if p.timeControlStatus == .playing { p.pause() } else { p.play() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
override func keyDown(with event: NSEvent) {
|
||||||
if let observer = timeObserver {
|
if event.keyCode == 53 { // Escape
|
||||||
player.removeTimeObserver(observer)
|
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
|
||||||
}
|
}
|
||||||
itemStatusObserver?.invalidate()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@ -1,35 +1,27 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import SwiftBricks
|
|
||||||
|
|
||||||
/// ProgressSlider自定义Widget — 播放进度条+拖动seek
|
/// ProgressSlider — 纯SwiftUI进度条,拖动seek
|
||||||
struct ProgressSliderWidget: View {
|
struct ProgressSlider: View {
|
||||||
let bridge: PlayerBridge
|
let player: AVPlayer
|
||||||
let schema: ControlSchema
|
@ObservedObject var bridge: PlayerBridge
|
||||||
@ObservedObject var engine: BricksEngine
|
@State private var isDragging = false
|
||||||
|
|
||||||
@State private var progress: Double = 0
|
|
||||||
@State private var duration: Double = 0
|
|
||||||
@State private var isDragging: Bool = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
// 背景轨道
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
RoundedRectangle(cornerRadius: 2)
|
||||||
.fill(Color.secondary.opacity(0.3))
|
.fill(Color.secondary.opacity(0.3))
|
||||||
.frame(height: 4)
|
.frame(height: 4)
|
||||||
|
|
||||||
// 已播放进度
|
|
||||||
RoundedRectangle(cornerRadius: 2)
|
RoundedRectangle(cornerRadius: 2)
|
||||||
.fill(Color.accentColor)
|
.fill(Color.accentColor)
|
||||||
.frame(width: geo.size.width * progressRatio, height: 4)
|
.frame(width: geo.size.width * bridge.progressRatio, height: 4)
|
||||||
|
|
||||||
// 拖动滑块
|
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.accentColor)
|
.fill(Color.accentColor)
|
||||||
.frame(width: 14, height: 14)
|
.frame(width: 14, height: 14)
|
||||||
.offset(x: geo.size.width * progressRatio - 7)
|
.offset(x: geo.size.width * bridge.progressRatio - 7)
|
||||||
.shadow(radius: 2)
|
.shadow(radius: 2)
|
||||||
}
|
}
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
@ -39,9 +31,9 @@ struct ProgressSliderWidget: View {
|
|||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
isDragging = true
|
isDragging = true
|
||||||
let ratio = max(0, min(1, value.location.x / geo.size.width))
|
let ratio = max(0, min(1, value.location.x / geo.size.width))
|
||||||
if duration > 0 {
|
if bridge.cachedDuration > 0 {
|
||||||
let seekTime = ratio * duration
|
let seekTime = ratio * bridge.cachedDuration
|
||||||
bridge.player.seek(to: CMTime(seconds: seekTime, preferredTimescale: 600))
|
player.seek(to: CMTime(seconds: seekTime, preferredTimescale: 600))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
@ -50,23 +42,5 @@ struct ProgressSliderWidget: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
.onReceive(engine.store.$values) { values in
|
|
||||||
if let val = values["progress_slider"] as? String {
|
|
||||||
let parts = val.split(separator: "/")
|
|
||||||
if parts.count == 2,
|
|
||||||
let cur = Double(parts[0]),
|
|
||||||
let total = Double(parts[1]) {
|
|
||||||
if !isDragging {
|
|
||||||
progress = cur
|
|
||||||
duration = total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var progressRatio: Double {
|
|
||||||
guard duration > 0 else { return 0 }
|
|
||||||
return max(0, min(1, progress / duration))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import SwiftBricks
|
|
||||||
|
|
||||||
/// VideoPlayer自定义Widget — 注册到BricksEngine的"VideoPlayer"类型
|
|
||||||
struct VideoPlayerWidget: View {
|
|
||||||
let bridge: PlayerBridge
|
|
||||||
let schema: ControlSchema
|
|
||||||
@ObservedObject var engine: BricksEngine
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VideoPlayerRepresentable(player: bridge.player)
|
|
||||||
.background(Color.black)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
bridge.togglePlayPause()
|
|
||||||
}
|
|
||||||
.onTapGesture(count: 2) {
|
|
||||||
bridge.toggleFullscreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 跨平台AVPlayer渲染
|
// MARK: - 跨平台AVPlayer渲染
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user