970 lines
36 KiB
Swift
970 lines
36 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
|
||
#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()
|
||
|
||
// 诊断日志
|
||
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)
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// MARK: - 初始化
|
||
func setup() {
|
||
#if os(macOS)
|
||
// 设置为正常 GUI 应用,显示在 Dock 中
|
||
NSApp.setActivationPolicy(.regular)
|
||
#elseif os(iOS)
|
||
// iOS: 配置音频会话(支持后台播放 + PiP)
|
||
configureAudioSession()
|
||
// 开启诊断日志文件
|
||
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
|
||
self?.showToast("Saved: \(url.lastPathComponent)")
|
||
}
|
||
hlsRecorder.onError = { [weak self] msg in
|
||
self?.showToast(msg)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
#if os(iOS)
|
||
/// 配置 AVAudioSession,支持后台音频和 PiP
|
||
private func configureAudioSession() {
|
||
do {
|
||
let session = AVAudioSession.sharedInstance()
|
||
try session.setCategory(.playback, mode: .default)
|
||
try session.setActive(true)
|
||
let route = session.currentRoute
|
||
let outputs = route.outputs.map { "\($0.portName)(\($0.portType.rawValue))" }.joined(separator: ", ")
|
||
NSLog("[MiniPlayer] AVAudioSession configured: playback, outputs=[%@]", outputs)
|
||
} catch {
|
||
NSLog("[MiniPlayer] AVAudioSession setup failed: %@", 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)
|
||
}
|
||
}
|
||
|
||
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
|
||
diagLog("Local file check: exists=\(exists) readable=\(readable) size=\(fileSize)")
|
||
}
|
||
|
||
// 先清除旧的 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)
|
||
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 isLocalFile = item.url.isFileURL
|
||
self?.diagLog("AVPlayerItem FAILED: \(errMsg) code=\(errCode) localFile=\(isLocalFile)")
|
||
self?.playerStatus = .error(errMsg)
|
||
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: - 循环模式
|
||
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")
|
||
for url in urls {
|
||
diagLog("processing: \(url.lastPathComponent)")
|
||
let didStartAccessing = url.startAccessingSecurityScopedResource()
|
||
diagLog("startAccessingSecurityScopedResource: \(didStartAccessing)")
|
||
defer {
|
||
if didStartAccessing { url.stopAccessingSecurityScopedResource() }
|
||
}
|
||
|
||
// 复制文件到 app Documents 目录(security-scoped URL 在 defer 后会失效)
|
||
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||
let localURL = docsDir.appendingPathComponent(url.lastPathComponent)
|
||
|
||
do {
|
||
// 如果同名文件已存在,加时间戳
|
||
if FileManager.default.fileExists(atPath: localURL.path) {
|
||
let ts = Int(Date().timeIntervalSince1970)
|
||
let nameWithoutExt = url.deletingPathExtension().lastPathComponent
|
||
let ext = url.pathExtension
|
||
let newURL = docsDir.appendingPathComponent("\(nameWithoutExt)_\(ts).\(ext)")
|
||
try FileManager.default.copyItem(at: url, to: newURL)
|
||
diagLog("copied to: \(newURL.lastPathComponent)")
|
||
addItem(url: newURL, name: url.lastPathComponent, type: "file")
|
||
} else {
|
||
try FileManager.default.copyItem(at: url, to: localURL)
|
||
diagLog("copied to: \(localURL.lastPathComponent)")
|
||
addItem(url: localURL, name: url.lastPathComponent, type: "file")
|
||
}
|
||
} catch {
|
||
diagLog("copy failed: \(error.localizedDescription)")
|
||
addItem(url: url, name: url.lastPathComponent, type: "file")
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
}
|