MiniPlayer/Sources/PlayerBridge.swift
yumoqing dd64a21404 feat: QR code for current media with channel info, cross-platform
- Add QR generation of the currently playing URL (macOS window / iOS sheet)
- Encode channel metadata (name/type/group/logo) in the QR payload as
  compact JSON; fall back to a plain URL when the payload is too long
  for a reliably scannable QR
- Scanning saves the entry into the local library with its channel
  metadata and starts playback; legacy plain-URL QR codes still work
- Add iOS QR scanner sheet (AVCaptureMetadataOutput) so scanning is no
  longer macOS-only; both scanners share handleScannedCode
- Add optional channel name / group fields to the Add URL dialog and
  thread group/logo through addURL/addItem into the library
- Restore session (queue + position) on launch and save on quit for
  macOS, not just iOS
- Bundle app icons: generate AppIcon.icns from the macOS iconset when
  missing, and compile Assets.xcassets into the iOS app
2026-10-04 12:07:00 +08:00

1188 lines
45 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
#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?
var qrDisplayController: QRDisplayWindowController?
#endif
/// iOS: 当前待显示的二维码 URL(非 nil 时弹出 sheet)
@Published var qrDisplayURL: String?
@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?
@Published var recorderErrorMessage: String? // 录制错误直接显示在界面上
#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: %@", url.lastPathComponent)
self?.recorderErrorMessage = nil
self?.showToast("Saved: \(url.lastPathComponent)")
self?.presentShareSheet(for: url)
}
hlsRecorder.onError = { [weak self] msg in
NSLog("[MiniPlayer] onError: %@", msg)
self?.recorderErrorMessage = msg
self?.showToast(msg)
}
#endif
}
#if os(iOS)
/// 直接通过 UIKit present UIActivityViewController,绕过 SwiftUI sheet 可能的多 sheet 冲突
func presentShareSheet(for url: URL) {
NSLog("[MiniPlayer] presentShareSheet: %@", url.lastPathComponent)
guard let windowScene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive })
?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first
else {
recorderErrorMessage = "No window scene available"
return
}
var topVC = windowScene.keyWindow?.rootViewController
?? windowScene.windows.first(where: { !$0.isHidden && $0.bounds.size != .zero })?.rootViewController
while let presented = topVC?.presentedViewController { topVC = presented }
guard let presenter = topVC else {
recorderErrorMessage = "No view controller to present share sheet"
return
}
let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
activityVC.completionWithItemsHandler = { _, _, _, _ in }
presenter.present(activityVC, animated: true)
NSLog("[MiniPlayer] presentShareSheet: presented successfully")
}
/// 配置 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, player: player)
}
#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, name: String? = nil, group: String? = nil, logo: String? = nil) {
let urlString = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
print("[MiniPlayer] addURL called: \(urlString)")
guard let url = URL(string: urlString) else { showToast(L.invalidURL); return }
let resolvedName = (name?.isEmpty == false ? name! :
(url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent))
let type = urlString.contains(".m3u8") ? "stream" : "url"
print("[MiniPlayer] Adding item: name=\(resolvedName), type=\(type)")
addItem(url: url, name: resolvedName, type: type, group: group, logoURL: logo)
}
func addItem(url: URL, name: String, type: String, group: String? = nil, logoURL: String? = nil) {
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,
group: group, logoURL: logoURL)
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()
#else
showQRScanner = true
#endif
}
// MARK: - 当前播放 URL 的二维码
/// 当前正在播放的媒体 URL(无播放内容时为 nil)
var currentURLString: String? {
guard currentIndex >= 0, currentIndex < queue.count else { return nil }
let s = queue[currentIndex].url.absoluteString
return s.isEmpty ? nil : s
}
/// 当前播放条目对应的媒体库条目(用于取频道分组/台标)
var currentLibraryItem: LibraryItem? {
guard currentIndex >= 0, currentIndex < queue.count,
let libID = queue[currentIndex].libraryID else { return nil }
return library.items.first { $0.id == libID }
}
/// 把当前播放的视频 URL + 频道信息编码成二维码载荷
func currentQRPayload() -> QRMediaPayload? {
guard let urlString = currentURLString else { return nil }
let item = queue[currentIndex]
let lib = currentLibraryItem
return QRMediaPayload(
url: urlString,
name: item.name,
type: item.mediaType,
group: lib?.group,
logo: lib?.logoURL,
from: "MiniPlayer"
)
}
/// 把当前播放的视频 URL(含频道信息)生成二维码并弹窗显示
func showCurrentQRCode() {
guard let payload = currentQRPayload() else {
showToast(L.noCurrentURL)
return
}
let text = payload.encoded()
#if os(macOS)
if qrDisplayController == nil {
qrDisplayController = QRDisplayWindowController()
}
qrDisplayController?.show(urlString: text, displayText: payload.url)
#else
qrDisplayURL = text
#endif
}
/// 处理扫码/粘贴得到的二维码内容:解析频道信息 → 入库 → 播放
/// 返回是否成功识别(无效内容返回 false,调用方决定提示)
@discardableResult
func handleScannedCode(_ code: String) -> Bool {
guard let payload = QRMediaPayload.decode(code),
let url = URL(string: payload.url) else { return false }
let name = (payload.name?.isEmpty == false ? payload.name! :
(url.lastPathComponent.isEmpty ? (url.host ?? payload.url) : url.lastPathComponent))
let type = payload.type?.isEmpty == false ? payload.type! :
(payload.url.contains(".m3u8") ? "stream" : "url")
// 入库:带上频道分组/台标;已存在则返回原有条目
let libItem = library.addItem(url: payload.url, name: name, type: type,
group: payload.group, logoURL: payload.logo)
// 已在队列则直接跳到该条播放
if let idx = queue.firstIndex(where: { $0.url.absoluteString == payload.url }) {
playIndex(idx)
return true
}
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name,
mediaType: type, libraryID: libItem.id))
playIndex(queue.count - 1)
return true
}
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()
}
}