MiniPlayer/Sources/PlayerBridge.swift
yumoqing 44708f39ef feat(iOS): add recording, media library, and local file import
- Wire up HLSRecorder for iOS stream recording (toggleRecording)
- Add MediaLibraryView sheet for iOS (toggleLibraryWindow)
- Add .fileImporter for iOS local file playback (openFileDialog)
- Add Assets.xcassets with app icons
- Add UniformTypeIdentifiers import for iOS
- Add handleFileImport() with security-scoped resource access
2026-07-01 22:55:35 +08:00

777 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import AVFoundation
import AVKit
#if os(macOS)
import AppKit
#endif
///
struct MediaItem: Identifiable, Equatable {
let id: String
var url: URL
var name: String
var mediaType: String
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()
#endif
// MARK: -
func setup() {
#if os(macOS)
// GUI Dock
NSApp.setActivationPolicy(.regular)
#endif
player.volume = volume
setupTimeObserver()
setupEndObserver()
#if os(macOS)
screenRecorder.onRecordingSaved = { [weak self] url in
self?.showToast("Saved: \(url.lastPathComponent)")
}
screenRecorder.onError = { [weak self] msg in
self?.showToast(msg)
}
#endif
#if os(iOS)
hlsRecorder.onRecordingSaved = { [weak self] url in
self?.showToast("Saved: \(url.lastPathComponent)")
}
hlsRecorder.onError = { [weak self] msg in
self?.showToast(msg)
}
#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]
print("[MiniPlayer] playIndex(\(index)): \(item.url.absoluteString)")
// KVO
itemStatusObserver?.invalidate()
itemStatusObserver = nil
// AVURLAsset HTTP headers HLS Referer/UA
let headers: [String: String] = [
"Referer": item.url.deletingLastPathComponent().absoluteString,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
]
let asset = AVURLAsset(url: item.url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
let playerItem = AVPlayerItem(asset: asset)
player.replaceCurrentItem(with: playerItem)
player.play()
isPlaying = true
print("[MiniPlayer] player.play() called, rate=\(player.rate)")
cachedDuration = 0
progressRatio = 0
currentTimeText = "00:00"
totalTimeText = "00:00"
//
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
print("[MiniPlayer] KVO status changed: \(pi.status.rawValue), error=\(pi.error?.localizedDescription ?? "none")")
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 errMsg = pi.error?.localizedDescription ?? "Unknown error"
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()
}
}
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
// URL
if queue.contains(where: { $0.url.absoluteString == urlString }) {
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))
if queue.count == 1 { playIndex(0) }
}
func removeItem(at index: Int) {
guard index >= 0 && index < queue.count else { return }
let wasPlaying = (index == currentIndex)
queue.remove(at: index)
if wasPlaying {
if queue.isEmpty {
currentIndex = -1
player.replaceCurrentItem(with: nil)
currentTimeText = "00:00"; totalTimeText = "00:00"; progressRatio = 0
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]) {
for url in urls {
// iOS: startAccessingSecurityScopedResource 访
let didStartAccessing = url.startAccessingSecurityScopedResource()
defer {
if didStartAccessing { url.stopAccessingSecurityScopedResource() }
}
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: -
/// view onDisappear
/// PlayerBridge
func cleanup() {
isTearingDown = true
// 退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()
}
}