428 lines
13 KiB
Swift
428 lines
13 KiB
Swift
import SwiftUI
|
||
import AVFoundation
|
||
import Combine
|
||
#if os(macOS)
|
||
import AppKit
|
||
#endif
|
||
|
||
/// 播放队列项
|
||
struct MediaItem: Identifiable, Equatable {
|
||
let id: String
|
||
var url: URL
|
||
var name: String
|
||
var mediaType: String
|
||
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
|
||
}
|
||
|
||
/// 循环模式
|
||
enum RepeatMode: String, CaseIterable {
|
||
case none = "不循环"
|
||
case single = "单曲循环"
|
||
case all = "列表循环"
|
||
|
||
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 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
|
||
|
||
// MARK: - 内部状态
|
||
let player = AVPlayer()
|
||
private var timeObserver: Any?
|
||
private var itemStatusObserver: NSKeyValueObservation?
|
||
private var cancellables = Set<AnyCancellable>()
|
||
private var preferredTrackIndex: Int = 0
|
||
|
||
#if os(macOS)
|
||
private var fullscreenWindow: NSWindow?
|
||
private var playlistWindow: NSWindow?
|
||
#endif
|
||
|
||
// MARK: - 初始化
|
||
func setup() {
|
||
player.volume = volume
|
||
|
||
setupTimeObserver()
|
||
setupPlaybackStatusObserver()
|
||
setupEndObserver()
|
||
}
|
||
|
||
// MARK: - 播放控制
|
||
func togglePlayPause() {
|
||
if player.timeControlStatus == .playing {
|
||
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 }
|
||
currentIndex = index
|
||
let item = queue[index]
|
||
|
||
let playerItem = AVPlayerItem(url: item.url)
|
||
player.replaceCurrentItem(with: playerItem)
|
||
player.play()
|
||
isPlaying = true
|
||
|
||
cachedDuration = 0
|
||
progressRatio = 0
|
||
currentTimeText = "00:00"
|
||
totalTimeText = "00:00"
|
||
|
||
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||
if pi.status == .readyToPlay {
|
||
Task { @MainActor in self?.loadDuration(pi) }
|
||
}
|
||
}
|
||
|
||
loadTrackInfo(playerItem)
|
||
showToast("正在播放: \(item.name)")
|
||
}
|
||
|
||
// MARK: - 播放结束
|
||
private func setupEndObserver() {
|
||
NotificationCenter.default.addObserver(
|
||
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor in self?.onPlaybackEnded() }
|
||
}
|
||
}
|
||
|
||
private func onPlaybackEnded() {
|
||
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: - 时间更新
|
||
private func setupTimeObserver() {
|
||
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
|
||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||
Task { @MainActor in self?.updateTimeDisplay(time: time) }
|
||
}
|
||
}
|
||
|
||
private func updateTimeDisplay(time: CMTime) {
|
||
let current = time.seconds
|
||
var total = player.currentItem?.duration.seconds ?? 0
|
||
|
||
if !total.isFinite || total <= 0 { total = cachedDuration }
|
||
else { cachedDuration = total }
|
||
|
||
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 = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||
}
|
||
|
||
// MARK: - 循环模式
|
||
func cycleRepeatMode() {
|
||
repeatMode = repeatMode.next
|
||
showToast("循环模式: \(repeatMode.rawValue)")
|
||
}
|
||
|
||
// MARK: - 全屏(视频内容全屏,非窗口全屏)
|
||
func toggleFullscreen() {
|
||
#if os(macOS)
|
||
if let fw = fullscreenWindow {
|
||
fw.close()
|
||
fullscreenWindow = nil
|
||
isFullscreen = false
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
return
|
||
}
|
||
|
||
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
|
||
|
||
// 使用纯 NSView + AVPlayerLayer,避免 SwiftUI 桥接崩溃
|
||
let playerView = FullscreenPlayerView(frame: screen.frame)
|
||
playerView.player = player
|
||
win.contentView = playerView
|
||
win.makeKeyAndOrderFront(nil)
|
||
// 确保键盘事件(Escape退出)能被接收
|
||
win.makeFirstResponder(playerView)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
fullscreenWindow = win
|
||
isFullscreen = true
|
||
#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")
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
func addURL(_ urlString: String) {
|
||
guard let url = URL(string: urlString) else { showToast("无效URL"); return }
|
||
let name = url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent
|
||
let type = urlString.contains(".m3u8") ? "stream" : "url"
|
||
addItem(url: url, name: name, type: type)
|
||
}
|
||
|
||
private func addItem(url: URL, name: String, type: String) {
|
||
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type))
|
||
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
|
||
} else {
|
||
currentIndex = min(index, queue.count - 1)
|
||
playIndex(currentIndex)
|
||
}
|
||
} else if index < currentIndex {
|
||
currentIndex -= 1
|
||
}
|
||
}
|
||
|
||
// MARK: - 播放列表窗口
|
||
@Published var showPlaylistSheet = false
|
||
@Published var lastInteraction = Date()
|
||
@Published var isInteracting = false
|
||
|
||
func recordInteraction() { lastInteraction = Date() }
|
||
|
||
func togglePlaylistWindow() {
|
||
#if os(macOS)
|
||
if let win = playlistWindow, win.isVisible {
|
||
win.close(); playlistWindow = nil; return
|
||
}
|
||
let win = NSWindow(
|
||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
||
styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false
|
||
)
|
||
win.title = "播放列表 (\(queue.count))"
|
||
win.isReleasedWhenClosed = false
|
||
win.center()
|
||
win.contentView = NSHostingView(rootView: PlaylistWindowView(bridge: self))
|
||
win.makeKeyAndOrderFront(nil)
|
||
playlistWindow = win
|
||
#else
|
||
showPlaylistSheet.toggle()
|
||
#endif
|
||
}
|
||
|
||
// 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: - 播放状态
|
||
private func setupPlaybackStatusObserver() {
|
||
player.publisher(for: \.timeControlStatus)
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak self] status in
|
||
self?.isPlaying = (status == .playing)
|
||
}
|
||
.store(in: &cancellables)
|
||
}
|
||
|
||
deinit {
|
||
if let observer = timeObserver { player.removeTimeObserver(observer) }
|
||
itemStatusObserver?.invalidate()
|
||
}
|
||
}
|
||
|
||
// MARK: - 全屏专用 NSView(纯 AppKit,不经过 SwiftUI)
|
||
#if os(macOS)
|
||
import AppKit
|
||
|
||
class FullscreenPlayerView: NSView {
|
||
var player: AVPlayer? {
|
||
get { (layer as? AVPlayerLayer)?.player }
|
||
set { (layer as? AVPlayerLayer)?.player = newValue }
|
||
}
|
||
|
||
override init(frame: NSRect) {
|
||
super.init(frame: frame)
|
||
wantsLayer = true
|
||
let playerLayer = AVPlayerLayer()
|
||
playerLayer.videoGravity = .resizeAspect
|
||
layer = playerLayer
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError() }
|
||
|
||
override var acceptsFirstResponder: Bool { true }
|
||
|
||
override func layout() {
|
||
super.layout()
|
||
(layer as? AVPlayerLayer)?.frame = bounds
|
||
}
|
||
|
||
override func mouseDown(with event: NSEvent) {
|
||
if event.clickCount == 2 {
|
||
// 双击退出全屏 — 通过通知
|
||
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
|
||
} else {
|
||
// 单击播放/暂停
|
||
if let p = player {
|
||
if p.timeControlStatus == .playing { p.pause() } else { p.play() }
|
||
}
|
||
}
|
||
}
|
||
|
||
override func keyDown(with event: NSEvent) {
|
||
if event.keyCode == 53 { // Escape
|
||
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
|
||
}
|
||
}
|
||
}
|
||
#endif
|