MiniPlayer/Sources/PlayerBridge.swift
yumoqing f68476d57f fix: move video capture off main thread + filter stale end-of-playback notifications
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).
2026-06-28 10:34:50 +08:00

535 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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 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()
}
}