MiniPlayer/Sources/PlayerBridge.swift

970 lines
36 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
#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
// AirPlayiOS 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"
/// UserDefaultsapp /退
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()
}
}