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:
yumoqing 2026-06-22 00:04:06 +08:00
parent e4ca9bc80a
commit 6811572b7e
5 changed files with 255 additions and 424 deletions

View File

@ -10,16 +10,11 @@ let package = Package(
products: [
.executable(name: "MiniPlayer", targets: ["MiniPlayer"])
],
dependencies: [
.package(path: "../SwiftBricks")
],
dependencies: [],
targets: [
.executableTarget(
name: "MiniPlayer",
dependencies: ["SwiftBricks"],
resources: [
.process("Resources")
]
dependencies: []
)
]
)

View File

@ -1,5 +1,4 @@
import SwiftUI
import SwiftBricks
import AVFoundation
@main
@ -27,6 +26,11 @@ struct MiniPlayerApp: App {
Button("全屏") { bridge.toggleFullscreen() }
.keyboardShortcut("f", modifiers: .command)
Divider()
Button("音量+") { bridge.adjustVolume(by: 0.1) }
.keyboardShortcut("=", modifiers: .command)
Button("音量-") { bridge.adjustVolume(by: -0.1) }
.keyboardShortcut("-", modifiers: .command)
Divider()
Button("循环模式") { bridge.cycleRepeatMode() }
.keyboardShortcut("r", modifiers: .command)
}
@ -42,22 +46,28 @@ struct MiniPlayerApp: App {
}
}
// MARK: - BricksView
// MARK: - SwiftUIBricksView
struct ContentView: View {
@ObservedObject var bridge: PlayerBridge
var body: some View {
ZStack {
// BricksViewVBox
//
VideoPlayerRepresentable(player: bridge.player)
.background(Color.black)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
// / +
Color.clear
.contentShape(Rectangle())
.onTapGesture { bridge.togglePlayPause() }
#if os(macOS)
.onTapGesture(count: 2) { bridge.toggleFullscreen() }
#endif
.ignoresSafeArea()
// Logo /Toolbar
// Logo
VStack {
HStack {
Button(action: { bridge.showToolbar.toggle() }) {
@ -75,7 +85,7 @@ struct ContentView: View {
Spacer()
}
// Toolbar
// Toolbarlogo
if bridge.showToolbar {
VStack {
Spacer()
@ -100,12 +110,16 @@ struct ContentView: View {
.sheet(isPresented: $bridge.showURLDialog) {
URLInputDialog(bridge: bridge)
}
#if os(macOS)
.sheet(isPresented: $bridge.showTrackDialog) {
TrackSelectDialog(bridge: bridge)
}
#endif
.sheet(isPresented: $bridge.showPlaylistSheet) {
PlaylistWindowView(bridge: bridge)
}
.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)
.font(.system(size: 12, design: .monospaced))
.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)
Text(bridge.totalTimeText)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white)
.frame(width: 44, alignment: .leading)
.frame(width: 50, alignment: .leading)
}
//
HStack(spacing: 10) {
#if os(macOS)
ToolbarButton(label: "📂 打开") { bridge.openFileDialog() }
#endif
ToolbarButton(label: "") { bridge.playPrev() }
ToolbarButton(label: bridge.isPlaying ? "" : "▶️") { bridge.togglePlayPause() }
ToolbarButton(label: "") { bridge.playNext() }
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)
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.7))
@ -154,10 +203,6 @@ struct ControlToolbar: View {
.padding(.vertical, 8)
.background(.black.opacity(0.55))
}
private var dummySchema: ControlSchema {
ControlSchema(id: "progress_slider", widgettype: "ProgressSlider", options: ControlOptions(), binds: nil, subwidgets: nil)
}
}
// MARK: -
@ -187,28 +232,20 @@ struct PlaylistWindowView: View {
var body: some View {
VStack(spacing: 0) {
//
HStack {
Button("📂 添加文件") { bridge.openFileDialog() }
Button("🔗 添加URL") { bridge.showURLDialog = true }
Spacer()
Text("\(bridge.queue.count)")
.font(.caption)
.foregroundColor(.secondary)
.font(.caption).foregroundColor(.secondary)
}
.padding(12)
.background(.regularMaterial)
Divider()
//
if bridge.queue.isEmpty {
VStack {
Spacer()
Text("暂无媒体请添加文件或URL")
.foregroundColor(.secondary)
Spacer()
}
VStack { Spacer(); Text("暂无媒体").foregroundColor(.secondary); Spacer() }
} else {
ScrollView {
LazyVStack(spacing: 2) {
@ -218,29 +255,18 @@ struct PlaylistWindowView: View {
Text("").foregroundColor(.accentColor).font(.caption)
}
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.font(.system(size: 13))
.lineLimit(1)
Text(item.mediaType)
.font(.caption)
.foregroundColor(.secondary)
Text(item.name).font(.system(size: 13)).lineLimit(1)
Text(item.mediaType).font(.caption).foregroundColor(.secondary)
}
Spacer()
Button(role: .destructive) {
bridge.removeItem(at: idx)
} label: {
Image(systemName: "trash")
.font(.caption)
}
.buttonStyle(.plain)
Button(role: .destructive) { bridge.removeItem(at: idx) } label: {
Image(systemName: "trash").font(.caption)
}.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(idx == bridge.currentIndex ? Color.accentColor.opacity(0.15) : Color.clear)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(idx == bridge.currentIndex ? Color.accentColor.opacity(0.15) : .clear)
.contentShape(Rectangle())
.onTapGesture {
bridge.playIndex(idx)
}
.onTapGesture { bridge.playIndex(idx) }
}
}
}
@ -265,10 +291,7 @@ struct URLInputDialog: View {
Button("取消") { dismiss() }
Spacer()
Button("添加") {
if !url.isEmpty {
bridge.addURL(url)
dismiss()
}
if !url.isEmpty { bridge.addURL(url); dismiss() }
}
.keyboardShortcut(.defaultAction)
.disabled(url.isEmpty)
@ -288,10 +311,7 @@ struct TrackSelectDialog: View {
VStack(spacing: 12) {
Text("选择音轨").font(.headline)
ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in
Button(action: {
bridge.selectTrack(index: idx)
dismiss()
}) {
Button(action: { bridge.selectTrack(index: idx); dismiss() }) {
HStack {
Text("Track \(idx + 1)")
Spacer()
@ -299,10 +319,8 @@ struct TrackSelectDialog: View {
Text("").foregroundColor(.green)
}
}
.padding(8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(8).contentShape(Rectangle())
}.buttonStyle(.plain)
}
Divider()
Button("关闭") { dismiss() }

View File

@ -1,15 +1,16 @@
import SwiftUI
import AVFoundation
import Combine
import SwiftBricks
#if os(macOS)
import AppKit
#endif
///
struct MediaItem: Identifiable, Equatable {
let id: String
var url: URL
var name: String
var mediaType: String // video/audio/stream
var mediaType: String
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
}
@ -36,15 +37,10 @@ enum RepeatMode: String, CaseIterable {
}
}
/// 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?
@ -56,150 +52,42 @@ final class PlayerBridge: ObservableObject {
@Published var currentTimeText = "00:00"
@Published var totalTimeText = "00:00"
@Published var currentTrackLabel = "🎵 Track 1"
//
private var playlistWindow: NSWindow?
@Published var volume: Float = 1.0
@Published var progressRatio: Double = 0
@Published var cachedDuration: Double = 0
@Published var queue: [MediaItem] = []
@Published var currentIndex: Int = -1
@Published var repeatMode: RepeatMode = .all
// 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 itemStatusObserver: NSKeyValueObservation?
private var cancellables = Set<AnyCancellable>()
//
private var cachedDuration: Double = 0
//
private var preferredTrackIndex: Int = 0
#if os(macOS)
private var fullscreenWindow: NSWindow?
private var playlistWindow: NSWindow?
#endif
// MARK: -
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()
//
setupPlaybackStatusObserver()
}
// 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)
}
}
setupEndObserver()
}
// MARK: -
func togglePlayPause() {
if player.timeControlStatus == .playing {
player.pause()
isPlaying = false
} else {
player.play()
isPlaying = true
}
}
@ -213,15 +101,11 @@ final class PlayerBridge: ObservableObject {
guard !queue.isEmpty else { return }
switch repeatMode {
case .none:
if currentIndex < queue.count - 1 {
playIndex(currentIndex + 1)
}
if currentIndex < queue.count - 1 { playIndex(currentIndex + 1) }
case .single:
player.seek(to: .zero)
player.play()
player.seek(to: .zero); player.play()
case .all:
let idx = (currentIndex + 1) % queue.count
playIndex(idx)
playIndex((currentIndex + 1) % queue.count)
}
}
@ -233,92 +117,62 @@ final class PlayerBridge: ObservableObject {
let playerItem = AVPlayerItem(url: item.url)
player.replaceCurrentItem(with: playerItem)
player.play()
isPlaying = true
//
cachedDuration = 0
progressRatio = 0
currentTimeText = "00:00"
totalTimeText = "00:00"
// item
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] item, _ in
if item.status == .readyToPlay {
Task { @MainActor in
self?.loadDuration(item)
}
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
if pi.status == .readyToPlay {
Task { @MainActor in self?.loadDuration(pi) }
}
}
//
loadTrackInfo(playerItem)
// UI
updatePlayButton(isPlaying: true)
updatePlaylistHighlight()
showToast("正在播放: \(item.name)")
}
// MARK: -
// MARK: -
private func setupEndObserver() {
NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: nil,
queue: .main
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.onPlaybackEnded()
}
Task { @MainActor in self?.onPlaybackEnded() }
}
}
private func onPlaybackEnded() {
switch repeatMode {
case .none:
if currentIndex < queue.count - 1 {
playNext()
} else {
updatePlayButton(isPlaying: false)
}
if currentIndex < queue.count - 1 { playNext() } else { isPlaying = false }
case .single:
player.seek(to: .zero)
player.play()
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)
}
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
var total = item.duration.seconds
var total = player.currentItem?.duration.seconds ?? 0
// duration
if !total.isFinite || total <= 0 {
total = cachedDuration
} else {
cachedDuration = total
}
if !total.isFinite || total <= 0 { total = cachedDuration }
else { cachedDuration = total }
currentTimeText = formatTime(current)
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)
} else {
eng.store.setValue(id: "time_current", value: formatTime(current))
currentTimeText = formatTime(current)
progressRatio = max(0, min(1, current / total))
}
}
@ -328,7 +182,6 @@ final class PlayerBridge: ObservableObject {
let secs = dur.seconds
if secs.isFinite && secs > 0 {
cachedDuration = secs
engine?.store.setValue(id: "time_total", value: formatTime(secs))
totalTimeText = formatTime(secs)
}
}
@ -337,13 +190,25 @@ final class PlayerBridge: ObservableObject {
private func formatTime(_ seconds: Double) -> String {
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
if h > 0 { return String(format: "%d:%02d:%02d", h, 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) {
Task {
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible) else {
@ -352,64 +217,70 @@ final class PlayerBridge: ObservableObject {
updateTrackLabel()
return
}
let options = group.options
availableTracks = options.enumerated().map { idx, _ in "Track \(idx + 1)" }
// 使
var targetIndex = preferredTrackIndex
if targetIndex >= options.count {
targetIndex = 0
}
//
if targetIndex >= options.count { targetIndex = 0 }
item.select(options[targetIndex], in: group)
currentTrackIndex = targetIndex
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
preferredTrackIndex = index //
preferredTrackIndex = index
updateTrackLabel()
}
}
private func updateTrackLabel() {
let label = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
engine?.store.setValue(id: "track_label", value: label)
currentTrackLabel = label
currentTrackLabel = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
}
// 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() {
#if os(macOS)
guard let window = NSApp.keyWindow ?? NSApp.windows.first else { return }
window.toggleFullScreen(nil)
if let fw = fullscreenWindow {
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
}
// MARK: -
func openFileDialog() {
#if os(macOS)
let panel = NSOpenPanel()
@ -419,115 +290,35 @@ final class PlayerBridge: ObservableObject {
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
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 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
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type))
if queue.count == 1 { playIndex(0) }
}
func removeItem(at index: Int) {
guard index >= 0 && index < queue.count else { return }
let wasPlaying = (index == currentIndex)
queue.remove(at: index)
if wasPlaying {
if queue.isEmpty {
currentIndex = -1
player.replaceCurrentItem(with: nil)
currentTimeText = "00:00"
totalTimeText = "00:00"
currentTimeText = "00:00"; totalTimeText = "00:00"; progressRatio = 0
} else {
currentIndex = min(index, queue.count - 1)
playIndex(currentIndex)
@ -535,24 +326,98 @@ final class PlayerBridge: ObservableObject {
} else if index < currentIndex {
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
private func showToast(_ message: String) {
toastMessage = message
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if self.toastMessage == message {
self.toastMessage = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
if self?.toastMessage == message { 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 {
if let observer = timeObserver {
player.removeTimeObserver(observer)
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 { // Escape
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
}
itemStatusObserver?.invalidate()
}
}
#endif

View File

@ -1,35 +1,27 @@
import SwiftUI
import AVFoundation
import SwiftBricks
/// ProgressSliderWidget +seek
struct ProgressSliderWidget: View {
let bridge: PlayerBridge
let schema: ControlSchema
@ObservedObject var engine: BricksEngine
@State private var progress: Double = 0
@State private var duration: Double = 0
@State private var isDragging: Bool = false
/// ProgressSlider SwiftUIseek
struct ProgressSlider: View {
let player: AVPlayer
@ObservedObject var bridge: PlayerBridge
@State private var isDragging = false
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
//
RoundedRectangle(cornerRadius: 2)
.fill(Color.secondary.opacity(0.3))
.frame(height: 4)
//
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor)
.frame(width: geo.size.width * progressRatio, height: 4)
.frame(width: geo.size.width * bridge.progressRatio, height: 4)
//
Circle()
.fill(Color.accentColor)
.frame(width: 14, height: 14)
.offset(x: geo.size.width * progressRatio - 7)
.offset(x: geo.size.width * bridge.progressRatio - 7)
.shadow(radius: 2)
}
.frame(height: 20)
@ -39,9 +31,9 @@ struct ProgressSliderWidget: View {
.onChanged { value in
isDragging = true
let ratio = max(0, min(1, value.location.x / geo.size.width))
if duration > 0 {
let seekTime = ratio * duration
bridge.player.seek(to: CMTime(seconds: seekTime, preferredTimescale: 600))
if bridge.cachedDuration > 0 {
let seekTime = ratio * bridge.cachedDuration
player.seek(to: CMTime(seconds: seekTime, preferredTimescale: 600))
}
}
.onEnded { _ in
@ -50,23 +42,5 @@ struct ProgressSliderWidget: View {
)
}
.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))
}
}

View File

@ -1,26 +1,5 @@
import SwiftUI
import AVFoundation
import SwiftBricks
/// VideoPlayerWidget 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