SwiftUI multiple .sheet modifiers can conflict — replaced the pendingExportURL sheet binding with a direct UIKit UIActivityViewController presentation from the key window's root VC. This bypasses any SwiftUI sheet stack issues. Also added copyLocalFile() for local file recording (FileManager.copyItem instead of URLSession.download which fails on file:// URLs).
1097 lines
41 KiB
Swift
1097 lines
41 KiB
Swift
import SwiftUI
|
||
import AVFoundation
|
||
import AVKit
|
||
#if os(macOS)
|
||
import AppKit
|
||
#elseif os(iOS)
|
||
import UIKit
|
||
#endif
|
||
|
||
/// 播放队列项
|
||
struct MediaItem: Identifiable, Equatable {
|
||
let id: String
|
||
var url: URL
|
||
var name: String
|
||
var mediaType: String
|
||
var libraryID: String? // 关联媒体库条目ID(用于播放记忆)
|
||
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
|
||
}
|
||
|
||
/// 播放器加载状态
|
||
enum PlayerStatus: Equatable {
|
||
case idle // 无媒体
|
||
case loading // 正在连接/加载
|
||
case buffering // 播放中缓冲
|
||
case playing // 正常播放
|
||
case error(String) // 播放错误
|
||
|
||
static func == (lhs: PlayerStatus, rhs: PlayerStatus) -> Bool {
|
||
switch (lhs, rhs) {
|
||
case (.idle, .idle), (.loading, .loading), (.buffering, .buffering), (.playing, .playing):
|
||
return true
|
||
case (.error(let a), .error(let b)):
|
||
return a == b
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 循环模式
|
||
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
|
||
@Published var showLibrarySheet = false
|
||
@Published var showFileImporter = false
|
||
@Published var showNetworkBrowser = false
|
||
#if os(macOS)
|
||
var qrScannerController: QRScannerWindowController?
|
||
#endif
|
||
@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
|
||
@Published var playerStatus: PlayerStatus = .idle
|
||
@Published var loadingElapsed: Int = 0 // 加载已用秒数
|
||
|
||
// MARK: - 内部状态
|
||
let player = AVPlayer()
|
||
let library = MediaLibrary()
|
||
// 用纯 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
|
||
private var loadingTimer: Timer?
|
||
|
||
#if os(macOS)
|
||
private var fullscreenWindow: NSWindow?
|
||
private var playlistWindow: NSWindow?
|
||
private var libraryWindow: NSWindow?
|
||
private var fullscreenEventMonitor: Any?
|
||
let screenRecorder = PlayerRecorder()
|
||
#endif
|
||
|
||
#if os(iOS)
|
||
let hlsRecorder = HLSRecorder()
|
||
let pipController = PiPController()
|
||
@Published var pendingExportURL: URL?
|
||
#endif
|
||
// 诊断日志
|
||
private var diagLogURL: URL?
|
||
private var diagLogHandle: FileHandle?
|
||
|
||
private func startDiagnosticLog() {
|
||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||
diagLogURL = docs.appendingPathComponent("miniplayer_diag.log")
|
||
// 清空旧日志
|
||
try? "".write(to: diagLogURL!, atomically: true, encoding: .utf8)
|
||
diagLogHandle = try? FileHandle(forWritingTo: diagLogURL!)
|
||
diagLogHandle?.seekToEndOfFile()
|
||
diagLog("=== MiniPlayer diagnostic log started ===")
|
||
}
|
||
|
||
func diagLog(_ msg: String) {
|
||
let line = "\(Date()): \(msg)\n"
|
||
print("[MiniPlayer] \(msg)")
|
||
if let data = line.data(using: .utf8) {
|
||
try? diagLogHandle?.write(contentsOf: data)
|
||
}
|
||
}
|
||
|
||
// MARK: - 初始化
|
||
func setup() {
|
||
#if os(macOS)
|
||
// 设置为正常 GUI 应用,显示在 Dock 中
|
||
NSApp.setActivationPolicy(.regular)
|
||
#elseif os(iOS)
|
||
// iOS: 配置音频会话(支持后台播放 + PiP)
|
||
ensureAudioSession()
|
||
// 开启诊断日志文件
|
||
startDiagnosticLog()
|
||
#endif
|
||
|
||
player.volume = volume
|
||
|
||
// 启用 AirPlay(iOS 和 macOS 通用)
|
||
player.allowsExternalPlayback = true
|
||
|
||
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
|
||
|
||
#if os(iOS)
|
||
hlsRecorder.onRecordingSaved = { [weak self] url in
|
||
NSLog("[MiniPlayer] onRecordingSaved called: %@", url.lastPathComponent)
|
||
self?.showToast("Saved: \(url.lastPathComponent)")
|
||
// 直接用 UIKit present,不依赖 SwiftUI sheet binding
|
||
self?.presentShareSheet(for: url)
|
||
}
|
||
hlsRecorder.onError = { [weak self] msg in
|
||
self?.showToast(msg)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
#if os(iOS)
|
||
/// 直接通过 UIKit present UIActivityViewController,绕过 SwiftUI sheet 可能的多 sheet 冲突
|
||
func presentShareSheet(for url: URL) {
|
||
guard let windowScene = UIApplication.shared.connectedScenes
|
||
.compactMap({ $0 as? UIWindowScene })
|
||
.first(where: { $0.activationState == .foregroundActive })
|
||
?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first
|
||
else { return }
|
||
|
||
var topVC = windowScene.keyWindow?.rootViewController
|
||
?? windowScene.windows.first(where: { !$0.isHidden && $0.bounds.size != .zero })?.rootViewController
|
||
while let presented = topVC?.presentedViewController { topVC = presented }
|
||
|
||
let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||
activityVC.completionWithItemsHandler = { _, _, _, _ in
|
||
// 录制完成后可以删掉临时文件?不,用户可能需要再次分享
|
||
}
|
||
topVC?.present(activityVC, animated: true)
|
||
NSLog("[MiniPlayer] presentShareSheet: presented for %@", url.lastPathComponent)
|
||
}
|
||
|
||
/// 配置 AVAudioSession,支持后台音频和 PiP
|
||
private func ensureAudioSession() {
|
||
let session = AVAudioSession.sharedInstance()
|
||
do {
|
||
try session.setCategory(.playback, mode: .default)
|
||
try session.setActive(true)
|
||
} catch {
|
||
diagLog("AudioSession error: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// MARK: - 录制
|
||
func toggleRecording() {
|
||
#if os(macOS)
|
||
if screenRecorder.isRecording {
|
||
screenRecorder.stopRecording()
|
||
} else {
|
||
guard !queue.isEmpty, currentIndex >= 0, currentIndex < queue.count else {
|
||
showToast("No media playing")
|
||
return
|
||
}
|
||
let item = queue[currentIndex]
|
||
screenRecorder.startRecording(url: item.url)
|
||
}
|
||
#elseif os(iOS)
|
||
if hlsRecorder.isRecording {
|
||
hlsRecorder.stopRecording()
|
||
} else {
|
||
guard !queue.isEmpty, currentIndex >= 0, currentIndex < queue.count else {
|
||
showToast("No media playing")
|
||
return
|
||
}
|
||
let item = queue[currentIndex]
|
||
hlsRecorder.startRecording(url: item.url)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// MARK: - 播放控制
|
||
func togglePlayPause() {
|
||
if player.timeControlStatus == .playing {
|
||
saveCurrentPosition()
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 播放器报错的诊断消息(存在这里,和错误一起显示)
|
||
private var lastDiagMsg = ""
|
||
|
||
func playIndex(_ index: Int) {
|
||
guard index >= 0 && index < queue.count else { return }
|
||
|
||
// 保存当前播放位置(播放记忆)
|
||
saveCurrentPosition()
|
||
|
||
currentIndex = index
|
||
let item = queue[index]
|
||
diagLog("playIndex(\(index)): \(item.url.lastPathComponent)")
|
||
|
||
// 本地文件诊断:检查文件是否存在和可读
|
||
if item.url.isFileURL {
|
||
let path = item.url.path
|
||
let exists = FileManager.default.fileExists(atPath: path)
|
||
let readable = FileManager.default.isReadableFile(atPath: path)
|
||
let fileSize = (try? FileManager.default.attributesOfItem(atPath: path)[.size] as? Int) ?? 0
|
||
// 读前4字节验证文件不是空的/损坏的
|
||
var magic: UInt32 = 0
|
||
if let fh = try? FileHandle(forReadingFrom: item.url) {
|
||
if let d = try? fh.read(upToCount: 4), d.count >= 4 {
|
||
magic = d.withUnsafeBytes { $0.load(as: UInt32.self) }
|
||
}
|
||
try? fh.close()
|
||
}
|
||
diagLog("Local file check: exists=\(exists) readable=\(readable) size=\(fileSize) magic=\(String(format:"%08X", magic))")
|
||
lastDiagMsg = "file: exists=\(exists) readable=\(readable)\nsize=\(fileSize) magic=\(String(format:"%08X", magic))"
|
||
}
|
||
|
||
// 先清除旧的 KVO,避免野指针
|
||
itemStatusObserver?.invalidate()
|
||
itemStatusObserver = nil
|
||
|
||
// 用 AVURLAsset 支持自定义 HTTP headers(某些 HLS 流需要 Referer/UA)
|
||
var assetOptions: [String: Any] = [:]
|
||
if item.url.scheme == "http" || item.url.scheme == "https" {
|
||
let headers: [String: String] = [
|
||
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
||
]
|
||
assetOptions["AVURLAssetHTTPHeaderFieldsKey"] = headers
|
||
}
|
||
let asset = AVURLAsset(url: item.url, options: assetOptions.isEmpty ? nil : assetOptions)
|
||
let playerItem = AVPlayerItem(asset: asset)
|
||
|
||
player.replaceCurrentItem(with: playerItem)
|
||
#if os(iOS)
|
||
ensureAudioSession()
|
||
#endif
|
||
player.play()
|
||
isPlaying = true
|
||
diagLog("play() called, rate=\(player.rate) timeControlStatus=\(player.timeControlStatus.rawValue)")
|
||
|
||
cachedDuration = 0
|
||
progressRatio = 0
|
||
currentTimeText = "00:00"
|
||
totalTimeText = "00:00"
|
||
|
||
// 开始加载状态追踪
|
||
playerStatus = .loading
|
||
loadingElapsed = 0
|
||
loadingTimer?.invalidate()
|
||
loadingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||
DispatchQueue.main.async {
|
||
guard let self = self else { return }
|
||
if case .loading = self.playerStatus {
|
||
self.loadingElapsed += 1
|
||
} else {
|
||
self.loadingTimer?.invalidate()
|
||
self.loadingTimer = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
// 查找媒体库条目,获取保存的位置
|
||
let savedPosition: Double = {
|
||
if let libID = item.libraryID {
|
||
return library.getPosition(for: libID)
|
||
}
|
||
return library.findItem(byURL: item.url.absoluteString)?.lastPosition ?? 0
|
||
}()
|
||
|
||
// KVO 可能从非主线程回调,用 DispatchQueue.main.async
|
||
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||
self?.diagLog("KVO status: \(pi.status.rawValue)")
|
||
DispatchQueue.main.async {
|
||
switch pi.status {
|
||
case .readyToPlay:
|
||
self?.playerStatus = .playing
|
||
self?.loadingTimer?.invalidate()
|
||
self?.loadingTimer = nil
|
||
self?.loadDuration(pi)
|
||
// 恢复播放位置(仅对点播内容,直播流跳过)
|
||
let dur = self?.cachedDuration ?? 0
|
||
if savedPosition > 1.0 && dur > 0 {
|
||
let pos = min(savedPosition, dur - 5)
|
||
if pos > 0 {
|
||
self?.player.seek(to: CMTime(seconds: pos, preferredTimescale: 600))
|
||
print("[MiniPlayer] Resumed at \(pos)s")
|
||
}
|
||
}
|
||
case .failed:
|
||
let err = pi.error
|
||
let errMsg = err?.localizedDescription ?? "Unknown error"
|
||
let errCode = (err as NSError?)?.code ?? 0
|
||
let errDomain = (err as NSError?)?.domain ?? "none"
|
||
let fullMsg = "\(errMsg)\n[code:\(errCode) domain:\(errDomain)]\n\(self?.lastDiagMsg ?? "")\n\(item.url.lastPathComponent)"
|
||
self?.diagLog("AVPlayerItem FAILED: \(errMsg) code=\(errCode) domain=\(errDomain)")
|
||
self?.playerStatus = .error(fullMsg)
|
||
self?.loadingTimer?.invalidate()
|
||
self?.loadingTimer = nil
|
||
self?.isPlaying = false
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
|
||
// 检测缓冲状态(仅在已 readyToPlay 后检测,避免覆盖 loading)
|
||
if case .playing = playerStatus {
|
||
if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
|
||
playerStatus = .buffering
|
||
}
|
||
} else if case .buffering = playerStatus {
|
||
if player.timeControlStatus == .playing {
|
||
playerStatus = .playing
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
func cycleTrack() {
|
||
guard availableTracks.count > 1 else { return }
|
||
let nextIndex = (currentTrackIndex + 1) % availableTracks.count
|
||
selectTrack(index: nextIndex)
|
||
}
|
||
|
||
private func updateTrackLabel() {
|
||
currentTrackLabel = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||
}
|
||
|
||
// MARK: - 外部字幕加载(骨架,后续 ASS 解析在此接入)
|
||
/// 存储当前加载的外部字幕文件 URL
|
||
@Published var externalSubtitleURL: URL?
|
||
|
||
// SMB 流式加载器引用(保持存活)
|
||
private var streamServer: LocalStreamServer?
|
||
|
||
func loadExternalSubtitle(url: URL) {
|
||
externalSubtitleURL = url
|
||
diagLog("External subtitle loaded: \(url.lastPathComponent)")
|
||
showToast("📝 \(url.lastPathComponent)")
|
||
}
|
||
|
||
/// SMB 文件流式播放 — 启动本地 HTTP 服务器,AVPlayer 从 localhost 播放
|
||
func playSMBSource(url smbURL: URL, name: String) {
|
||
// 停止旧服务器
|
||
streamServer?.stop()
|
||
|
||
let server = LocalStreamServer(smbURL: smbURL)
|
||
do {
|
||
try server.start()
|
||
} catch {
|
||
diagLog("Stream server start failed: \(error)")
|
||
showToast("Failed: \(error.localizedDescription)")
|
||
return
|
||
}
|
||
self.streamServer = server
|
||
|
||
guard let localURL = server.localURL else {
|
||
showToast("Failed to get stream URL")
|
||
return
|
||
}
|
||
|
||
diagLog("Streaming \(name) via \(localURL.absoluteString)")
|
||
|
||
let asset = AVURLAsset(url: localURL)
|
||
let playerItem = AVPlayerItem(asset: asset)
|
||
player.replaceCurrentItem(with: playerItem)
|
||
#if os(iOS)
|
||
ensureAudioSession()
|
||
#endif
|
||
player.play()
|
||
isPlaying = true
|
||
|
||
cachedDuration = 0; progressRatio = 0
|
||
currentTimeText = "00:00"; totalTimeText = "00:00"
|
||
playerStatus = .loading; loadingElapsed = 0
|
||
|
||
loadingTimer?.invalidate()
|
||
loadingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||
DispatchQueue.main.async {
|
||
guard let self = self else { return }
|
||
if case .loading = self.playerStatus { self.loadingElapsed += 1 }
|
||
else { self.loadingTimer?.invalidate(); self.loadingTimer = nil }
|
||
}
|
||
}
|
||
|
||
itemStatusObserver?.invalidate()
|
||
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||
DispatchQueue.main.async {
|
||
switch pi.status {
|
||
case .readyToPlay:
|
||
self?.playerStatus = .playing
|
||
self?.loadingTimer?.invalidate(); self?.loadingTimer = nil
|
||
self?.loadDuration(pi)
|
||
case .failed:
|
||
let err = pi.error; let msg = err?.localizedDescription ?? "Unknown"
|
||
self?.playerStatus = .error(msg)
|
||
self?.loadingTimer?.invalidate(); self?.loadingTimer = nil
|
||
self?.isPlaying = false
|
||
default: break
|
||
}
|
||
}
|
||
}
|
||
|
||
showToast(L.nowPlaying(name))
|
||
}
|
||
|
||
// MARK: - 网络文件浏览
|
||
func toggleNetworkBrowser() {
|
||
showNetworkBrowser = true
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
}
|
||
#elseif os(iOS)
|
||
showFileImporter = true
|
||
#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) {
|
||
let urlString = url.absoluteString
|
||
print("[MiniPlayer] addItem: name=\(name), type=\(type), scheme=\(url.scheme ?? "nil")")
|
||
// 检查队列是否已有相同URL
|
||
if queue.contains(where: { $0.url.absoluteString == urlString }) {
|
||
print("[MiniPlayer] addItem: already in queue, skipping")
|
||
showToast("⚠️ \(name) already in queue")
|
||
return
|
||
}
|
||
// 自动入库(库内已自动去重)
|
||
let libItem = library.addItem(url: urlString, name: name, type: type)
|
||
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name,
|
||
mediaType: type, libraryID: libItem.id))
|
||
print("[MiniPlayer] addItem: added to queue, count=\(queue.count)")
|
||
if queue.count == 1 {
|
||
print("[MiniPlayer] addItem: first item, calling playIndex(0)")
|
||
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
|
||
playerStatus = .idle
|
||
} 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: - 播放位置记忆
|
||
|
||
/// 保存当前播放位置到媒体库
|
||
func saveCurrentPosition() {
|
||
guard currentIndex >= 0, currentIndex < queue.count else { return }
|
||
let position = player.currentTime().seconds
|
||
guard position.isFinite && position > 1.0 else { return }
|
||
|
||
let item = queue[currentIndex]
|
||
if let libID = item.libraryID {
|
||
library.savePosition(libID, position: position)
|
||
} else if let libItem = library.findItem(byURL: item.url.absoluteString) {
|
||
library.savePosition(libItem.id, position: position)
|
||
}
|
||
}
|
||
|
||
/// 强制保存位置(退出时,不等debounce)
|
||
func saveCurrentPositionImmediate() {
|
||
guard currentIndex >= 0, currentIndex < queue.count else { return }
|
||
let position = player.currentTime().seconds
|
||
guard position.isFinite && position > 1.0 else { return }
|
||
|
||
let item = queue[currentIndex]
|
||
if let libID = item.libraryID {
|
||
library.savePositionImmediate(libID, position: position)
|
||
} else if let libItem = library.findItem(byURL: item.url.absoluteString) {
|
||
library.savePositionImmediate(libItem.id, position: position)
|
||
}
|
||
}
|
||
|
||
// MARK: - 媒体库操作
|
||
|
||
/// 从媒体库播放条目(替换当前播放,保存位置)
|
||
func playFromLibrary(_ item: LibraryItem) {
|
||
guard let url = URL(string: item.url) else {
|
||
showToast(L.invalidURL)
|
||
return
|
||
}
|
||
// 添加到队列并播放
|
||
let mediaItem = MediaItem(id: UUID().uuidString, url: url, name: item.name,
|
||
mediaType: item.type, libraryID: item.id)
|
||
queue.append(mediaItem)
|
||
playIndex(queue.count - 1)
|
||
}
|
||
|
||
/// 添加到队列但不播放(用于批量加入)
|
||
func addItemNoPlay(url: URL, name: String, type: String, libraryID: String? = nil) {
|
||
// 队列去重
|
||
if queue.contains(where: { $0.url.absoluteString == url.absoluteString }) {
|
||
return
|
||
}
|
||
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name,
|
||
mediaType: type, libraryID: libraryID))
|
||
// 如果队列为空则自动播放第一个
|
||
if queue.count == 1 { playIndex(0) }
|
||
}
|
||
|
||
/// 导入本地文件到媒体库
|
||
func importLocalToLibrary() {
|
||
#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 {
|
||
var count = 0
|
||
for url in panel.urls {
|
||
library.addItem(url: url.absoluteString, name: url.lastPathComponent, type: "local")
|
||
addItemNoPlay(url: url, name: url.lastPathComponent, type: "local",
|
||
libraryID: library.findItem(byURL: url.absoluteString)?.id)
|
||
count += 1
|
||
}
|
||
showToastMsg("\(L.imported) \(count) \(L.localFilesAdded)")
|
||
}
|
||
#elseif os(iOS)
|
||
showFileImporter = true
|
||
#endif
|
||
}
|
||
|
||
/// 处理 iOS 文件选择器结果
|
||
func handleFileImport(_ urls: [URL]) {
|
||
diagLog("handleFileImport: \(urls.count) URLs")
|
||
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||
|
||
for url in urls {
|
||
diagLog("processing: \(url.lastPathComponent)")
|
||
let didStartAccessing = url.startAccessingSecurityScopedResource()
|
||
diagLog("startAccessingSecurityScopedResource: \(didStartAccessing)")
|
||
|
||
// 先把文件数据读到内存(安全域访问有效期内)
|
||
let fileData: Data
|
||
do {
|
||
fileData = try Data(contentsOf: url)
|
||
diagLog("read \(fileData.count) bytes from source")
|
||
} catch {
|
||
diagLog("read failed: \(error.localizedDescription)")
|
||
if didStartAccessing { url.stopAccessingSecurityScopedResource() }
|
||
continue
|
||
}
|
||
|
||
// 撤销安全域访问(文件数据已在内存中)
|
||
if didStartAccessing { url.stopAccessingSecurityScopedResource() }
|
||
|
||
// 写入 Documents,确保用唯一的文件名
|
||
let baseName = url.deletingPathExtension().lastPathComponent
|
||
let ext = url.pathExtension
|
||
var localURL = docsDir.appendingPathComponent(url.lastPathComponent)
|
||
if FileManager.default.fileExists(atPath: localURL.path) {
|
||
let ts = Int(Date().timeIntervalSince1970)
|
||
localURL = docsDir.appendingPathComponent("\(baseName)_\(ts).\(ext)")
|
||
}
|
||
|
||
do {
|
||
try fileData.write(to: localURL)
|
||
diagLog("written \(fileData.count) bytes to: \(localURL.lastPathComponent)")
|
||
addItem(url: localURL, name: url.lastPathComponent, type: "file")
|
||
} catch {
|
||
diagLog("write failed: \(error.localizedDescription)")
|
||
showToast("Failed to import: \(url.lastPathComponent)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 媒体库窗口
|
||
|
||
func toggleLibraryWindow() {
|
||
#if os(macOS)
|
||
if let win = libraryWindow, win.isVisible {
|
||
win.close(); libraryWindow = nil; return
|
||
}
|
||
let panel = NSPanel(
|
||
contentRect: NSRect(x: 0, y: 0, width: 700, height: 550),
|
||
styleMask: [.titled, .closable, .resizable, .utilityWindow], backing: .buffered, defer: false
|
||
)
|
||
panel.title = L.mediaLibrary
|
||
panel.isReleasedWhenClosed = false
|
||
panel.isMovableByWindowBackground = true
|
||
panel.hidesOnDeactivate = false
|
||
panel.becomesKeyOnlyIfNeeded = true
|
||
panel.center()
|
||
panel.contentView = NSHostingView(rootView: MediaLibraryView(library: library, bridge: self))
|
||
panel.makeKeyAndOrderFront(NSApp)
|
||
libraryWindow = panel
|
||
#elseif os(iOS)
|
||
showLibrarySheet.toggle()
|
||
#endif
|
||
}
|
||
|
||
// MARK: - 公开 Toast
|
||
|
||
func showToastMsg(_ message: String) {
|
||
showToast(message)
|
||
}
|
||
|
||
// 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: - 队列持久化(跨启动恢复)
|
||
|
||
private static let queueKey = "MiniPlayer.savedQueue"
|
||
private static let indexKey = "MiniPlayer.savedIndex"
|
||
private static let positionKey = "MiniPlayer.savedPosition"
|
||
private static let repeatModeKey = "MiniPlayer.savedRepeatMode"
|
||
|
||
/// 保存当前队列和播放位置到 UserDefaults(app 进后台/退出时调用)
|
||
func saveSessionState() {
|
||
guard !queue.isEmpty, currentIndex >= 0 else { return }
|
||
|
||
let items = queue.map { item -> [String: String] in
|
||
[
|
||
"url": item.url.absoluteString,
|
||
"name": item.name,
|
||
"type": item.mediaType,
|
||
"libraryID": item.libraryID ?? ""
|
||
]
|
||
}
|
||
|
||
let position = player.currentTime().seconds
|
||
|
||
UserDefaults.standard.set(items, forKey: Self.queueKey)
|
||
UserDefaults.standard.set(currentIndex, forKey: Self.indexKey)
|
||
UserDefaults.standard.set(position.isFinite ? position : 0, forKey: Self.positionKey)
|
||
UserDefaults.standard.set(repeatMode.rawValue, forKey: Self.repeatModeKey)
|
||
UserDefaults.standard.synchronize()
|
||
|
||
// 同时保存媒体库位置
|
||
library.saveSync()
|
||
|
||
NSLog("[MiniPlayer] Session saved: \(queue.count) items, index=\(currentIndex), position=\(position)")
|
||
}
|
||
|
||
/// 从 UserDefaults 恢复队列和播放位置(app 启动时调用)
|
||
func restoreSessionState() {
|
||
guard let items = UserDefaults.standard.array(forKey: Self.queueKey) as? [[String: String]],
|
||
!items.isEmpty else {
|
||
NSLog("[MiniPlayer] No saved session to restore")
|
||
return
|
||
}
|
||
|
||
let savedIndex = UserDefaults.standard.integer(forKey: Self.indexKey)
|
||
let savedPosition = UserDefaults.standard.double(forKey: Self.positionKey)
|
||
|
||
if let modeStr = UserDefaults.standard.string(forKey: Self.repeatModeKey),
|
||
let mode = RepeatMode(rawValue: modeStr) {
|
||
repeatMode = mode
|
||
}
|
||
|
||
// 重建队列
|
||
var restoredQueue: [MediaItem] = []
|
||
for dict in items {
|
||
guard let urlStr = dict["url"], let url = URL(string: urlStr) else { continue }
|
||
let name = dict["name"] ?? url.lastPathComponent
|
||
let type = dict["type"] ?? "url"
|
||
let libID = dict["libraryID"]?.isEmpty == false ? dict["libraryID"] : nil
|
||
restoredQueue.append(MediaItem(id: UUID().uuidString, url: url, name: name,
|
||
mediaType: type, libraryID: libID))
|
||
}
|
||
|
||
guard !restoredQueue.isEmpty else {
|
||
NSLog("[MiniPlayer] Restored queue is empty after URL parsing")
|
||
return
|
||
}
|
||
|
||
queue = restoredQueue
|
||
let idx = min(savedIndex, restoredQueue.count - 1)
|
||
|
||
NSLog("[MiniPlayer] Restoring session: \(restoredQueue.count) items, index=\(idx), position=\(savedPosition)")
|
||
|
||
// 播放并 seek 到上次位置
|
||
playIndex(idx)
|
||
|
||
// seek 需要等 item readyToPlay,用延迟方式
|
||
if savedPosition > 1.0 {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
||
guard let self else { return }
|
||
let dur = self.cachedDuration
|
||
if dur > 0 && savedPosition < dur - 5 {
|
||
self.player.seek(to: CMTime(seconds: savedPosition, preferredTimescale: 600))
|
||
self.showToast("Resumed at \(self.formatTime(savedPosition))")
|
||
NSLog("[MiniPlayer] Resumed at \(savedPosition)s")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 清理
|
||
/// 主动清理所有观察者,在 view onDisappear 时调用
|
||
/// 必须在 PlayerBridge 被释放之前调用
|
||
func cleanup() {
|
||
isTearingDown = true
|
||
|
||
// 保存队列和播放位置(跨启动恢复)
|
||
saveSessionState()
|
||
|
||
// 强制保存播放位置到媒体库(退出前记忆,不等debounce)
|
||
saveCurrentPositionImmediate()
|
||
|
||
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
|
||
loadingTimer?.invalidate()
|
||
loadingTimer = nil
|
||
|
||
// 2. 移除 KVO 观察者
|
||
itemStatusObserver?.invalidate(); itemStatusObserver = nil
|
||
|
||
// 3. 移除通知观察者
|
||
if let token = endObserverToken {
|
||
NotificationCenter.default.removeObserver(token)
|
||
endObserverToken = nil
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
// deinit 是 nonisolated,只做最小安全清理
|
||
// 大部分清理应在 cleanup() 中完成
|
||
updateTimer?.invalidate()
|
||
loadingTimer?.invalidate()
|
||
}
|
||
}
|