247 lines
7.5 KiB
Swift
247 lines
7.5 KiB
Swift
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)
|
|
}
|
|
}
|