MiniPlayer/Sources/PlayerBridge.swift
yumoqing 3e3a990f5e fix: replace Combine KVO with NSKeyValueObservation to prevent autorelease crash
- Remove Combine import and cancellables
- Use NSKeyValueObservation for timeControlStatus (controlled teardown)
- Invalidate itemStatusObserver before replacing playerItem
- Store endObserver token for proper removal
- Add cleanup() method called on view disappear
- Proper deinit with direct property cleanup (nonisolated-safe)
2026-06-22 00:36:18 +08:00

447 lines
14 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 Combine
#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 = "不循环"
case single = "单曲循环"
case all = "列表循环"
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()
var cancellables = Set<AnyCancellable>()
private var timeObserver: Any?
private var itemStatusObserver: NSKeyValueObservation?
private var playbackStatusObserver: NSKeyValueObservation?
private var endObserverToken: NSObjectProtocol?
private var preferredTrackIndex: Int = 0
#if os(macOS)
private var fullscreenWindow: NSWindow?
private var playlistWindow: NSWindow?
#endif
// MARK: -
func setup() {
player.volume = volume
setupTimeObserver()
setupPlaybackStatusObserver()
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"
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
if pi.status == .readyToPlay {
Task { @MainActor in self?.loadDuration(pi) }
}
}
loadTrackInfo(playerItem)
showToast("正在播放: \(item.name)")
}
// MARK: -
private func setupEndObserver() {
endObserverToken = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.onPlaybackEnded() }
}
}
private func onPlaybackEnded() {
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: -
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) }
}
}
private func updateTimeDisplay(time: CMTime) {
let current = time.seconds
var total = player.currentItem?.duration.seconds ?? 0
if !total.isFinite || total <= 0 { total = cachedDuration }
else { cachedDuration = total }
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 = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
}
// MARK: -
func cycleRepeatMode() {
repeatMode = repeatMode.next
showToast("循环模式: \(repeatMode.rawValue)")
}
// MARK: -
func toggleFullscreen() {
#if os(macOS)
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()
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("无效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) {
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
// @Published onHover
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 = "播放列表 (\(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: -
private func setupPlaybackStatusObserver() {
playbackStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] p, _ in
let playing = (p.timeControlStatus == .playing)
Task { @MainActor in self?.isPlaying = playing }
}
}
/// app 退
func cleanup() {
if let obs = timeObserver { player.removeTimeObserver(obs); timeObserver = nil }
itemStatusObserver?.invalidate(); itemStatusObserver = nil
playbackStatusObserver?.invalidate(); playbackStatusObserver = nil
if let token = endObserverToken { NotificationCenter.default.removeObserver(token); endObserverToken = nil }
player.pause()
player.replaceCurrentItem(with: nil)
}
deinit {
if let obs = timeObserver { player.removeTimeObserver(obs) }
itemStatusObserver?.invalidate()
playbackStatusObserver?.invalidate()
if let token = endObserverToken { NotificationCenter.default.removeObserver(token) }
player.pause()
player.replaceCurrentItem(with: nil)
}
}
// 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() }
}
}
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 { // Escape
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
}
}
}
#endif