MiniPlayer/Sources/PlayerEngine.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)
}
}