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