parent
49693e95b4
commit
9c934235d6
@ -0,0 +1,103 @@ |
||||
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. |
||||
} |
||||
} |
||||
Loading…
Reference in new issue