import Foundation import AVFoundation import Combine import SwiftUI @MainActor final class PlayerEngine: ObservableObject { let player = AVPlayer() @Published var queue: [MediaItem] = [] @Published var currentIndex: Int = -1 @Published var isPlaying: Bool = false @Published var currentTime: Double = 0 @Published var duration: Double = 0 @Published var repeatMode: RepeatMode = .all @Published var isFullscreen: Bool = false // 音轨 @Published var audioTracks: [AVMediaSelectionOption] = [] @Published var selectedAudioTrack: AVMediaSelectionOption? private var currentAsset: AVAsset? private var timeObserver: Any? private var itemObserver: NSObjectProtocol? private var bookmarks: [URL: Data] = [:] var currentItem: MediaItem? { guard currentIndex >= 0, currentIndex < queue.count else { return nil } return queue[currentIndex] } init() { let interval = CMTime(seconds: 0.25, preferredTimescale: 600) timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self else { return } Task { @MainActor in self.currentTime = time.seconds if let dur = self.player.currentItem?.duration.seconds, dur.isFinite && dur > 0 { self.duration = dur } } } NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor in self?.onItemFinished() } } } deinit { if let obs = timeObserver { player.removeTimeObserver(obs) } if let obs = itemObserver { NotificationCenter.default.removeObserver(obs) } } // MARK: - 播放控制 func play() { player.play() isPlaying = true } func pause() { player.pause() isPlaying = false } func togglePlay() { if isPlaying { pause() } else { play() } } func seek(to seconds: Double) { player.seek(to: CMTime(seconds: seconds, preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero) } func seekForward(_ seconds: Double = 10) { seek(to: min(currentTime + seconds, duration)) } func seekBackward(_ seconds: Double = 10) { seek(to: max(currentTime - seconds, 0)) } // MARK: - 列表控制 func play(index: Int) { guard index >= 0, index < queue.count else { return } currentIndex = index loadAndPlay(item: queue[index]) } func playNext() { guard !queue.isEmpty else { return } let next = (currentIndex + 1) % queue.count play(index: next) } func playPrevious() { guard !queue.isEmpty else { return } if currentTime > 3 { seek(to: 0) return } let prev = (currentIndex - 1 + queue.count) % queue.count play(index: prev) } func addToQueue(urls: [URL]) { for url in urls { let type: MediaType = { let ext = url.pathExtension.lowercased() if ["m3u8", "m3u"].contains(ext) { return .stream } if ["mp3", "aac", "flac", "wav", "ogg", "m4a", "wma", "opus"].contains(ext) { return .audio } return .video }() let name = url.deletingPathExtension().lastPathComponent queue.append(MediaItem(url: url, name: name, type: type)) } if currentIndex == -1, let first = queue.first { currentIndex = 0 loadAndPlay(item: first) } } func remove(at offsets: IndexSet) { let removingCurrent = offsets.contains(currentIndex) queue.remove(atOffsets: offsets) if queue.isEmpty { currentIndex = -1 pause() player.replaceCurrentItem(with: nil) duration = 0 currentTime = 0 } else if removingCurrent { currentIndex = min(currentIndex, queue.count - 1) play(index: currentIndex) } else { // Adjust index if items before current were removed let before = offsets.filter { $0 < currentIndex }.count currentIndex -= before } } func move(from source: IndexSet, to destination: Int) { let oldCurrent = currentIndex queue.move(fromOffsets: source, toOffset: destination) // Recalculate currentIndex if let oldPos = queue.firstIndex(where: { $0.url == currentItem?.url }) { currentIndex = oldPos } else { currentIndex = oldCurrent } } // MARK: - 音轨 func selectAudioTrack(_ option: AVMediaSelectionOption) { guard let group = currentAsset?.mediaSelectionGroup(forMediaCharacteristic: .audible) else { return } player.currentItem?.select(option, in: group) selectedAudioTrack = option } // MARK: - 全屏 func toggleFullscreen() { isFullscreen.toggle() } // MARK: - 内部 private func onItemFinished() { switch repeatMode { case .single: seek(to: 0) play() case .all: playNext() case .none: if currentIndex < queue.count - 1 { playNext() } else { pause() } } } private func loadAndPlay(item: MediaItem) { #if os(iOS) // Start accessing security-scoped resource for local files if item.url.startAccessingSecurityScopedResource() { // Store bookmark for persistent access if let bookmark = try? item.url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil) { bookmarks[item.url] = bookmark } } #endif let asset: AVAsset if item.type == .stream { asset = AVURLAsset(url: item.url) } else { asset = AVURLAsset(url: item.url) } currentAsset = asset audioTracks = [] selectedAudioTrack = nil let playerItem = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: playerItem) // Load duration and audio tracks Task { do { let dur = try await asset.load(.duration) if dur.seconds.isFinite && dur.seconds > 0 { self.duration = dur.seconds } let group = try await asset.loadMediaSelectionGroup(for: .audible) if let group { let options = group.options self.audioTracks = options self.selectedAudioTrack = options.first } } catch { print("Asset load error: \(error)") } } play() } // MARK: - 时间格式化 static func formatTime(_ seconds: Double) -> String { guard seconds.isFinite && seconds >= 0 else { return "0: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: "%d:%02d", m, s) } }