Two fixes for video freeze during recording: 1. Video capture loop (30fps) moved from main-thread Timer to a dedicated DispatchSourceTimer on 'miniplayer.videoCapture' queue. copyPixelBuffer() on the main thread was competing with AVPlayer's rendering pipeline for CPU time, causing frame stalls. 2. AVPlayerItemDidPlayToEndTime observer now checks notification.object against player.currentItem. Previously object:nil caught stale notifications from replaced items, causing spurious playNext() calls that interrupted playback (visible as duplicate playIndex in logs).
535 lines
18 KiB
Swift
535 lines
18 KiB
Swift
import SwiftUI
|
||
import AVFoundation
|
||
import AVKit
|
||
#if os(macOS)
|
||
import AppKit
|
||
#endif
|
||
|
||
/// 播放队列项
|
||
struct MediaItem: Identifiable, Equatable {
|
||
let id: String
|
||
var url: URL
|
||
var name: String
|
||
var mediaType: String
|
||
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
|
||
}
|
||
|
||
/// 循环模式
|
||
enum RepeatMode: String, CaseIterable {
|
||
case none = "none"
|
||
case single = "single"
|
||
case all = "all"
|
||
|
||
var displayName: String {
|
||
switch self {
|
||
case .none: return L.repeatNone
|
||
case .single: return L.repeatSingle
|
||
case .all: return L.repeatAll
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class PlayerBridge: ObservableObject {
|
||
|
||
// MARK: - Published状态
|
||
@Published var showURLDialog = false
|
||
@Published var showTrackDialog = false
|
||
@Published var showQRScanner = false
|
||
var qrScannerController: QRScannerWindowController?
|
||
@Published var toastMessage: String?
|
||
@Published var availableTracks: [String] = []
|
||
@Published var currentTrackIndex: Int = 0
|
||
@Published var isFullscreen = false
|
||
@Published var showToolbar = false
|
||
@Published var isPlaying = false
|
||
@Published var currentTimeText = "00:00"
|
||
@Published var totalTimeText = "00:00"
|
||
@Published var currentTrackLabel = "🎵 Track 1"
|
||
@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()
|
||
// 用纯 Swift Timer 代替 addPeriodicTimeObserver
|
||
// addPeriodicTimeObserver 内部的 FigNotificationCenter weak listener 机制
|
||
// 与 autorelease pool drain 存在竞态,导致双重释放野指针崩溃
|
||
private var updateTimer: Timer?
|
||
private var itemStatusObserver: NSKeyValueObservation?
|
||
private var endObserverToken: NSObjectProtocol?
|
||
private var preferredTrackIndex: Int = 0
|
||
private var isTearingDown = false
|
||
|
||
#if os(macOS)
|
||
private var fullscreenWindow: NSWindow?
|
||
private var playlistWindow: NSWindow?
|
||
private var fullscreenEventMonitor: Any?
|
||
let screenRecorder = PlayerRecorder()
|
||
var videoOutput: AVPlayerItemVideoOutput?
|
||
#endif
|
||
|
||
// MARK: - 初始化
|
||
func setup() {
|
||
#if os(macOS)
|
||
// 设置为正常 GUI 应用,显示在 Dock 中
|
||
NSApp.setActivationPolicy(.regular)
|
||
#endif
|
||
|
||
player.volume = volume
|
||
|
||
setupTimeObserver()
|
||
setupEndObserver()
|
||
|
||
#if os(macOS)
|
||
screenRecorder.onRecordingSaved = { [weak self] url in
|
||
self?.showToast("Saved: \(url.lastPathComponent)")
|
||
}
|
||
screenRecorder.onError = { [weak self] msg in
|
||
self?.showToast(msg)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// MARK: - 录制
|
||
func toggleRecording() {
|
||
#if os(macOS)
|
||
if screenRecorder.isRecording {
|
||
screenRecorder.stopRecording()
|
||
} else {
|
||
guard let output = videoOutput else {
|
||
showToast("No video playing")
|
||
return
|
||
}
|
||
guard !queue.isEmpty else { return }
|
||
let startTime = player.currentTime()
|
||
guard let playerItem = player.currentItem else {
|
||
showToast("No player item")
|
||
return
|
||
}
|
||
screenRecorder.startRecording(from: output, playerItem: playerItem, startTime: startTime)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// MARK: - 播放控制
|
||
func togglePlayPause() {
|
||
if player.timeControlStatus == .playing {
|
||
player.pause()
|
||
isPlaying = false
|
||
} else {
|
||
player.play()
|
||
isPlaying = true
|
||
}
|
||
}
|
||
|
||
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:
|
||
playIndex((currentIndex + 1) % queue.count)
|
||
}
|
||
}
|
||
|
||
func playIndex(_ index: Int) {
|
||
guard index >= 0 && index < queue.count else { return }
|
||
currentIndex = index
|
||
let item = queue[index]
|
||
print("[MiniPlayer] playIndex(\(index)): \(item.url.absoluteString)")
|
||
|
||
// 先清除旧的 KVO,避免野指针
|
||
itemStatusObserver?.invalidate()
|
||
itemStatusObserver = nil
|
||
|
||
// 用 AVURLAsset 支持自定义 HTTP headers(某些 HLS 流需要 Referer/UA)
|
||
let headers: [String: String] = [
|
||
"Referer": item.url.deletingLastPathComponent().absoluteString,
|
||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
||
]
|
||
let asset = AVURLAsset(url: item.url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
||
let playerItem = AVPlayerItem(asset: asset)
|
||
|
||
#if os(macOS)
|
||
// 添加 video output 用于录制
|
||
let pixelBufferAttributes: [String: Any] = [
|
||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
|
||
]
|
||
let output = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferAttributes)
|
||
playerItem.add(output)
|
||
videoOutput = output
|
||
|
||
// 预装音频 Tap(在播放前设置 audioMix,避免播放中设置导致视频卡顿)
|
||
screenRecorder.installAudioTap(on: playerItem)
|
||
#endif
|
||
|
||
player.replaceCurrentItem(with: playerItem)
|
||
player.play()
|
||
isPlaying = true
|
||
print("[MiniPlayer] player.play() called, rate=\(player.rate)")
|
||
|
||
cachedDuration = 0
|
||
progressRatio = 0
|
||
currentTimeText = "00:00"
|
||
totalTimeText = "00:00"
|
||
|
||
// KVO 可能从非主线程回调,用 DispatchQueue.main.async
|
||
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||
print("[MiniPlayer] KVO status changed: \(pi.status.rawValue), error=\(pi.error?.localizedDescription ?? "none")")
|
||
if pi.status == .readyToPlay {
|
||
DispatchQueue.main.async { self?.loadDuration(pi) }
|
||
}
|
||
}
|
||
|
||
loadTrackInfo(playerItem)
|
||
showToast(L.nowPlaying(item.name))
|
||
}
|
||
|
||
// MARK: - 播放结束
|
||
private func setupEndObserver() {
|
||
endObserverToken = NotificationCenter.default.addObserver(
|
||
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
|
||
) { [weak self] notification in
|
||
guard let self = self else { return }
|
||
// 只处理当前 item 的结束通知,忽略旧 item 被替换时发出的残留通知
|
||
guard let endedItem = notification.object as? AVPlayerItem,
|
||
endedItem === self.player.currentItem else { return }
|
||
Task { @MainActor in self.onPlaybackEnded() }
|
||
}
|
||
}
|
||
|
||
private func onPlaybackEnded() {
|
||
guard !isTearingDown else { return }
|
||
switch repeatMode {
|
||
case .none:
|
||
if currentIndex < queue.count - 1 { playNext() } else { isPlaying = false }
|
||
case .single:
|
||
player.seek(to: .zero); player.play()
|
||
case .all:
|
||
playNext()
|
||
}
|
||
}
|
||
|
||
// MARK: - 时间更新(纯 Timer,不经过 CoreMedia)
|
||
private func setupTimeObserver() {
|
||
updateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||
// Timer.scheduledTimer 的回调在添加它的 RunLoop 线程上
|
||
// 由于 PlayerBridge 是 @MainActor 且 setup() 在主线程调用,
|
||
// Timer 被添加到主线程 RunLoop
|
||
// 但 Timer 回调不是 @MainActor,所以需要 dispatch
|
||
DispatchQueue.main.async {
|
||
guard let self = self, !self.isTearingDown else { return }
|
||
self.updateTimeDisplay()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func updateTimeDisplay() {
|
||
// 只用 player.currentTime() 和 cachedDuration,
|
||
// 不访问 item.duration(会触发 CoreMedia FigNotificationCenter 竞态)
|
||
let current = player.currentTime().seconds
|
||
let total = cachedDuration
|
||
|
||
// 更新播放状态(替代 KVO,避免后台线程竞态)
|
||
isPlaying = (player.rate > 0 && player.error == nil)
|
||
|
||
currentTimeText = formatTime(current)
|
||
if total.isFinite && total > 0 {
|
||
totalTimeText = formatTime(total)
|
||
progressRatio = max(0, min(1, current / total))
|
||
}
|
||
}
|
||
|
||
private func loadDuration(_ item: AVPlayerItem) {
|
||
Task {
|
||
if let dur = try? await item.asset.load(.duration) {
|
||
let secs = dur.seconds
|
||
if secs.isFinite && secs > 0 {
|
||
cachedDuration = secs
|
||
totalTimeText = formatTime(secs)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func formatTime(_ seconds: Double) -> String {
|
||
guard seconds.isFinite && seconds >= 0 else { return "00:00" }
|
||
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: - 音量
|
||
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 {
|
||
availableTracks = ["Track 1"]
|
||
currentTrackIndex = 0
|
||
updateTrackLabel()
|
||
return
|
||
}
|
||
let options = group.options
|
||
availableTracks = options.enumerated().map { idx, _ in "Track \(idx + 1)" }
|
||
var targetIndex = preferredTrackIndex
|
||
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
|
||
updateTrackLabel()
|
||
}
|
||
}
|
||
|
||
private func updateTrackLabel() {
|
||
currentTrackLabel = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||
}
|
||
|
||
// MARK: - 循环模式
|
||
func cycleRepeatMode() {
|
||
repeatMode = repeatMode.next
|
||
showToast(L.repeatModeLabel(repeatMode.displayName))
|
||
}
|
||
|
||
// MARK: - 全屏(视频内容全屏,非窗口全屏)
|
||
// 全屏窗口只创建一次,之后只用 orderFront/orderOut 切换显示
|
||
// 使用 NSHostingView + SwiftUI,与主窗口共享相同的图标和控制栏
|
||
func toggleFullscreen() {
|
||
#if os(macOS)
|
||
if isFullscreen {
|
||
// 退出全屏:只隐藏窗口,不销毁
|
||
fullscreenWindow?.orderOut(nil)
|
||
isFullscreen = false
|
||
if let monitor = fullscreenEventMonitor {
|
||
NSEvent.removeMonitor(monitor)
|
||
fullscreenEventMonitor = nil
|
||
}
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
return
|
||
}
|
||
|
||
// 首次全屏:创建窗口和 SwiftUI 内容(仅此一次)
|
||
if fullscreenWindow == nil {
|
||
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
|
||
win.isReleasedWhenClosed = false
|
||
|
||
// 用 SwiftUI PlayerContentView 渲染全屏(含图标+控制栏)
|
||
let hostingView = NSHostingView(rootView: PlayerContentView(bridge: self))
|
||
hostingView.frame = screen.frame
|
||
win.contentView = hostingView
|
||
fullscreenWindow = win
|
||
}
|
||
|
||
fullscreenWindow?.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
isFullscreen = true
|
||
|
||
// Escape 键退出全屏
|
||
if fullscreenEventMonitor == nil {
|
||
fullscreenEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in
|
||
guard let self = self, self.isFullscreen else { return event }
|
||
guard event.window === self.fullscreenWindow else { return event }
|
||
|
||
if event.type == .keyDown && event.keyCode == 53 { // Escape
|
||
DispatchQueue.main.async { self.toggleFullscreen() }
|
||
return nil
|
||
}
|
||
return event
|
||
}
|
||
}
|
||
#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")
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
func addURL(_ urlString: String) {
|
||
let urlString = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
print("[MiniPlayer] addURL called: \(urlString)")
|
||
guard let url = URL(string: urlString) else { showToast(L.invalidURL); return }
|
||
let name = url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent
|
||
let type = urlString.contains(".m3u8") ? "stream" : "url"
|
||
print("[MiniPlayer] Adding item: name=\(name), type=\(type)")
|
||
addItem(url: url, name: name, type: type)
|
||
}
|
||
|
||
func addItem(url: URL, name: String, type: String) {
|
||
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"; progressRatio = 0
|
||
} else {
|
||
currentIndex = min(index, queue.count - 1)
|
||
playIndex(currentIndex)
|
||
}
|
||
} else if index < currentIndex {
|
||
currentIndex -= 1
|
||
}
|
||
}
|
||
|
||
// MARK: - 播放列表窗口
|
||
@Published var showPlaylistSheet = false
|
||
var lastInteraction = Date()
|
||
var isInteracting = false
|
||
|
||
func recordInteraction() { lastInteraction = Date() }
|
||
|
||
// MARK: - 扫码窗口
|
||
func showQRScannerWindow() {
|
||
#if os(macOS)
|
||
if qrScannerController == nil {
|
||
qrScannerController = QRScannerWindowController(bridge: self)
|
||
}
|
||
qrScannerController?.show()
|
||
#endif
|
||
}
|
||
|
||
func togglePlaylistWindow() {
|
||
#if os(macOS)
|
||
if let win = playlistWindow, win.isVisible {
|
||
win.close(); playlistWindow = nil; return
|
||
}
|
||
let panel = NSPanel(
|
||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
||
styleMask: [.titled, .closable, .resizable, .utilityWindow], backing: .buffered, defer: false
|
||
)
|
||
panel.title = "\(L.playlist) (\(queue.count))"
|
||
panel.isReleasedWhenClosed = false
|
||
panel.isMovableByWindowBackground = true
|
||
panel.hidesOnDeactivate = false
|
||
panel.becomesKeyOnlyIfNeeded = true
|
||
panel.center()
|
||
panel.contentView = NSHostingView(rootView: PlaylistWindowView(bridge: self))
|
||
panel.makeKeyAndOrderFront(NSApp)
|
||
playlistWindow = panel
|
||
#else
|
||
showPlaylistSheet.toggle()
|
||
#endif
|
||
}
|
||
|
||
// MARK: - Toast
|
||
private func showToast(_ message: String) {
|
||
toastMessage = message
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||
if self?.toastMessage == message { self?.toastMessage = nil }
|
||
}
|
||
}
|
||
|
||
// MARK: - 播放状态(已改为在 updateTimeDisplay 中检查 player.rate)
|
||
|
||
// MARK: - 清理
|
||
/// 主动清理所有观察者,在 view onDisappear 时调用
|
||
/// 必须在 PlayerBridge 被释放之前调用
|
||
func cleanup() {
|
||
isTearingDown = true
|
||
player.pause()
|
||
|
||
#if os(macOS)
|
||
// 移除全屏事件监控(不销毁窗口,避免 teardown 竞态)
|
||
if let monitor = fullscreenEventMonitor {
|
||
NSEvent.removeMonitor(monitor)
|
||
fullscreenEventMonitor = nil
|
||
}
|
||
fullscreenWindow?.orderOut(nil)
|
||
#endif
|
||
|
||
// 1. 先停 Timer
|
||
updateTimer?.invalidate()
|
||
updateTimer = nil
|
||
|
||
// 2. 移除 KVO 观察者
|
||
itemStatusObserver?.invalidate(); itemStatusObserver = nil
|
||
|
||
// 3. 移除通知观察者
|
||
if let token = endObserverToken {
|
||
NotificationCenter.default.removeObserver(token)
|
||
endObserverToken = nil
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
// deinit 是 nonisolated,只做最小安全清理
|
||
// 大部分清理应在 cleanup() 中完成
|
||
updateTimer?.invalidate()
|
||
}
|
||
}
|