MiniPlayer/Sources/PlayerBridge.swift
yumoqing c7bb63bb7e fix: HLS播放加自定义headers + 录制finishWriting错误日志
HLS播放:
- AVURLAsset替代AVPlayerItem(url:),加Referer/UA headers
- 某些HLS流服务端校验Referer,裸URL请求被拒绝

录制:
- startWriting检查返回值和error
- finishWriting回调打印status/error/frames
- 失败时删除损坏的临时文件,弹出错误提示
2026-06-24 22:59:44 +08:00

526 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 sourceURL = queue[currentIndex].url
let startTime = player.currentTime()
screenRecorder.startRecording(from: output, sourceURL: sourceURL, 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
#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() {
// 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: -
// 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) {
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()
}
}