diff --git a/Music/Services/AudioService.swift b/Music/Services/AudioService.swift new file mode 100644 index 0000000..000059b --- /dev/null +++ b/Music/Services/AudioService.swift @@ -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. + } +}