You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
103 lines
3.1 KiB
103 lines
3.1 KiB
import AVFoundation
|
|
import Observation
|
|
|
|
// With the project-wide `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` setting,
|
|
// this class is implicitly @MainActor. That's intentional: @Observable properties
|
|
// (isPlaying, currentTime, duration, volume) drive the UI and must be updated on
|
|
// the main actor. `deinit` is marked `nonisolated` because Swift does not allow
|
|
// actor-isolated deinits — it simply nils out the player reference so ARC handles
|
|
// cleanup safely without crossing actor boundaries.
|
|
@Observable
|
|
final class AudioService {
|
|
var isPlaying = false
|
|
var currentTime: Double = 0
|
|
var duration: Double = 0
|
|
var volume: Float = 0.65 {
|
|
didSet { player?.volume = volume }
|
|
}
|
|
|
|
private var player: AVPlayer?
|
|
private var timeObserver: Any?
|
|
private var endObserver: NSObjectProtocol?
|
|
|
|
var onTrackFinished: (() -> Void)?
|
|
|
|
func play(url: URL) {
|
|
cleanup()
|
|
|
|
let item = AVPlayerItem(url: url)
|
|
player = AVPlayer(playerItem: item)
|
|
player?.volume = volume
|
|
|
|
timeObserver = player?.addPeriodicTimeObserver(
|
|
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
|
|
queue: .main
|
|
) { [weak self] time in
|
|
guard let self else { return }
|
|
self.currentTime = time.seconds
|
|
if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite {
|
|
self.duration = dur.seconds
|
|
}
|
|
}
|
|
|
|
endObserver = NotificationCenter.default.addObserver(
|
|
forName: .AVPlayerItemDidPlayToEndTime,
|
|
object: item,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.isPlaying = false
|
|
self?.currentTime = 0
|
|
self?.onTrackFinished?()
|
|
}
|
|
|
|
player?.play()
|
|
isPlaying = true
|
|
}
|
|
|
|
func pause() {
|
|
player?.pause()
|
|
isPlaying = false
|
|
}
|
|
|
|
func resume() {
|
|
player?.play()
|
|
isPlaying = true
|
|
}
|
|
|
|
func togglePlayPause() {
|
|
if isPlaying { pause() } else { resume() }
|
|
}
|
|
|
|
func seek(to time: Double) {
|
|
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
|
|
}
|
|
|
|
func stop() {
|
|
cleanup()
|
|
isPlaying = false
|
|
currentTime = 0
|
|
duration = 0
|
|
}
|
|
|
|
private func cleanup() {
|
|
if let obs = timeObserver {
|
|
player?.removeTimeObserver(obs)
|
|
timeObserver = nil
|
|
}
|
|
if let obs = endObserver {
|
|
NotificationCenter.default.removeObserver(obs)
|
|
endObserver = nil
|
|
}
|
|
player?.pause()
|
|
player = nil
|
|
}
|
|
|
|
// `nonisolated` is required because Swift does not allow actor-isolated deinits.
|
|
// The player and observers are nilled out here; ARC will handle actual deallocation
|
|
// safely without needing to hop to the main actor.
|
|
nonisolated deinit {
|
|
// Cannot call the MainActor-isolated cleanup() from deinit.
|
|
// The weak references in closures will be invalidated when self is deallocated,
|
|
// and AVPlayer/NotificationCenter observers are released via ARC.
|
|
}
|
|
}
|
|
|