MiniPlayer/Sources/PlayerBridge.swift
yumoqing 6bbd86006a fix: nil AVPlayerLayer.player before closing fullscreen window
Root cause: when FullscreenPlayerView deallocates during window close,
AVPlayerLayer.player reference is released via autorelease pool drain
on main thread. CoreMedia background threads simultaneously access
sFigNotificationCenterWeakListenerLinks dictionary (weak listener
cleanup), causing use-after-free race condition.

Fix:
- toggleFullscreen(): set playerView.player = nil BEFORE fw.close()
- FullscreenPlayerView.viewWillMove(toWindow: nil): safety net to
  nil out player when view is removed from window by any means

This ensures the AVPlayer reference is released synchronously on the
main thread, before any autorelease pool drain can race with CoreMedia
internal cleanup threads.
2026-06-22 01:11:40 +08:00

479 lines
16 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
#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 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?
#endif
// MARK: -
func setup() {
player.volume = volume
setupTimeObserver()
setupEndObserver()
}
// 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]
// KVO
itemStatusObserver?.invalidate()
itemStatusObserver = nil
let playerItem = AVPlayerItem(url: item.url)
player.replaceCurrentItem(with: playerItem)
player.play()
isPlaying = true
cachedDuration = 0
progressRatio = 0
currentTimeText = "00:00"
totalTimeText = "00:00"
// KVO 线 DispatchQueue.main.async
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
if pi.status == .readyToPlay {
DispatchQueue.main.async { self?.loadDuration(pi) }
}
}
loadTrackInfo(playerItem)
showToast(L.nowPlaying(item.name))
}
// MARK: -
private func setupEndObserver() {
// queue: .main 线 Task
endObserverToken = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
) { [weak self] _ in
guard let self = self 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: -
func toggleFullscreen() {
#if os(macOS)
if let fw = fullscreenWindow {
// AVPlayerLayer player autorelease pool drain
// CoreMedia 线 sFigNotificationCenterWeakListenerLinks
if let playerView = fw.contentView as? FullscreenPlayerView {
playerView.player = 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
let playerView = FullscreenPlayerView(frame: screen.frame)
playerView.player = player
win.contentView = playerView
win.makeKeyAndOrderFront(nil)
win.makeFirstResponder(playerView)
NSApp.activate(ignoringOtherApps: true)
fullscreenWindow = win
isFullscreen = true
#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) {
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"
addItem(url: url, name: name, type: type)
}
private 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() }
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 = "\(L.playlist) (\(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) { [weak self] in
if self?.toastMessage == message { self?.toastMessage = nil }
}
}
// MARK: - updateTimeDisplay player.rate
// MARK: -
/// view onDisappear
/// PlayerBridge
func cleanup() {
isTearingDown = true
player.pause()
// 1. Timer
updateTimer?.invalidate()
updateTimer = nil
// 2. KVO
itemStatusObserver?.invalidate(); itemStatusObserver = nil
// 3.
if let token = endObserverToken {
NotificationCenter.default.removeObserver(token)
endObserverToken = nil
}
// 4. replaceCurrentItem(with: nil)
// playerItem player CoreMedia
}
deinit {
// deinit nonisolated
// cleanup()
updateTimer?.invalidate()
}
}
// MARK: - NSView AppKit SwiftUI
#if os(macOS)
import AppKit
class FullscreenPlayerView: NSView {
// 使 makeBackingLayer AppKit AVPlayerLayer
override func makeBackingLayer() -> CALayer { AVPlayerLayer() }
private var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
var player: AVPlayer? {
get { playerLayer.player }
set { playerLayer.player = newValue }
}
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
playerLayer.videoGravity = .resizeAspect
}
required init?(coder: NSCoder) { fatalError() }
override var acceptsFirstResponder: Bool { true }
// player
// CoreMedia 线 autorelease pool drain
override func viewWillMove(toWindow newWindow: NSWindow?) {
if newWindow == nil {
playerLayer.player = nil
}
super.viewWillMove(toWindow: newWindow)
}
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() }
}
}
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 { // Escape
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
}
}
}
#endif