Fix SwiftData persistence concurrency stability
This commit is contained in:
@@ -278,8 +278,9 @@ public final class PlaybackController {
|
||||
}
|
||||
|
||||
do {
|
||||
let fileURL = try localFileURL(for: currentTrack)
|
||||
try engine.loadTrack(
|
||||
at: URL(fileURLWithPath: currentTrack.localFilePath),
|
||||
at: fileURL,
|
||||
startTime: startTime
|
||||
)
|
||||
loadedTrackID = currentTrack.id
|
||||
@@ -299,15 +300,16 @@ public final class PlaybackController {
|
||||
nowPlayingState.isPlaying = false
|
||||
nowPlayingState.currentTrack = currentTrack
|
||||
nowPlayingState.currentTime = startTime
|
||||
nowPlayingState.duration = effectiveDuration(for: currentTrack)
|
||||
nowPlayingState.duration = currentTrack.durationSeconds ?? 0
|
||||
applyPlaybackError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreTrack(_ track: LibraryTrack, position: Double) {
|
||||
do {
|
||||
let fileURL = try localFileURL(for: track)
|
||||
try engine.loadTrack(
|
||||
at: URL(fileURLWithPath: track.localFilePath),
|
||||
at: fileURL,
|
||||
startTime: position
|
||||
)
|
||||
loadedTrackID = track.id
|
||||
@@ -320,7 +322,7 @@ public final class PlaybackController {
|
||||
nowPlayingState.currentTrack = track
|
||||
nowPlayingState.isPlaying = false
|
||||
nowPlayingState.currentTime = position
|
||||
nowPlayingState.duration = effectiveDuration(for: track)
|
||||
nowPlayingState.duration = track.durationSeconds ?? 0
|
||||
applyPlaybackError(error)
|
||||
}
|
||||
|
||||
@@ -378,6 +380,14 @@ public final class PlaybackController {
|
||||
return track?.durationSeconds ?? 0
|
||||
}
|
||||
|
||||
private func localFileURL(for track: LibraryTrack) throws -> URL {
|
||||
guard !track.localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw PlaybackError.missingLocalFile(path: track.localFilePath)
|
||||
}
|
||||
|
||||
return URL(fileURLWithPath: track.localFilePath)
|
||||
}
|
||||
|
||||
private func startProgressTimer() {
|
||||
progressTimer?.invalidate()
|
||||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) {
|
||||
|
||||
@@ -44,19 +44,19 @@ public final class AVFoundationPlaybackEngine: NSObject, PlaybackEngine, AVAudio
|
||||
}
|
||||
|
||||
public func loadTrack(at fileURL: URL, startTime: Double) throws {
|
||||
guard fileManager.fileExists(atPath: fileURL.path) else {
|
||||
throw PlaybackError.missingLocalFile(path: fileURL.path)
|
||||
}
|
||||
|
||||
do {
|
||||
unloadCurrentTrack()
|
||||
try validatePlayableFile(at: fileURL)
|
||||
let audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
|
||||
audioPlayer.delegate = self
|
||||
audioPlayer.prepareToPlay()
|
||||
audioPlayer.currentTime = min(max(startTime, 0), audioPlayer.duration)
|
||||
self.audioPlayer = audioPlayer
|
||||
} catch let error as PlaybackError {
|
||||
unloadCurrentTrack()
|
||||
throw error
|
||||
} catch {
|
||||
unloadCurrentTrack()
|
||||
throw PlaybackError.failedToLoadTrack(path: fileURL.path)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,38 @@ public final class AVFoundationPlaybackEngine: NSObject, PlaybackEngine, AVAudio
|
||||
audioPlayer.currentTime = min(max(time, 0), audioPlayer.duration)
|
||||
}
|
||||
|
||||
private func validatePlayableFile(at fileURL: URL) throws {
|
||||
let filePath = Self.displayPath(for: fileURL)
|
||||
guard fileURL.isFileURL else {
|
||||
throw PlaybackError.missingLocalFile(path: filePath)
|
||||
}
|
||||
|
||||
guard !fileURL.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw PlaybackError.missingLocalFile(path: filePath)
|
||||
}
|
||||
|
||||
guard fileManager.fileExists(atPath: fileURL.path) else {
|
||||
throw PlaybackError.missingLocalFile(path: filePath)
|
||||
}
|
||||
|
||||
guard (try? fileURL.checkResourceIsReachable()) == true else {
|
||||
throw PlaybackError.missingLocalFile(path: filePath)
|
||||
}
|
||||
}
|
||||
|
||||
private func unloadCurrentTrack() {
|
||||
audioPlayer?.stop()
|
||||
audioPlayer = nil
|
||||
}
|
||||
|
||||
private static func displayPath(for fileURL: URL) -> String {
|
||||
if fileURL.isFileURL {
|
||||
return fileURL.path
|
||||
}
|
||||
|
||||
return fileURL.absoluteString
|
||||
}
|
||||
|
||||
nonisolated public func audioPlayerDidFinishPlaying(
|
||||
_ player: AVAudioPlayer,
|
||||
successfully flag: Bool
|
||||
|
||||
@@ -18,9 +18,19 @@ public enum PlaybackError: Error, LocalizedError, Equatable, Hashable, Sendable
|
||||
case .noTrackLoaded:
|
||||
return "No audio track is currently loaded."
|
||||
case .missingLocalFile(let path):
|
||||
return "The local file could not be found: \(path)"
|
||||
let safePath = Self.safeDisplayPath(from: path)
|
||||
if safePath.isEmpty {
|
||||
return "The local file could not be found."
|
||||
}
|
||||
|
||||
return "The local file could not be found: \(safePath)"
|
||||
case .failedToLoadTrack(let path):
|
||||
return "The audio file could not be opened: \(path)"
|
||||
let safePath = Self.safeDisplayPath(from: path)
|
||||
if safePath.isEmpty {
|
||||
return "The audio file could not be opened."
|
||||
}
|
||||
|
||||
return "The audio file could not be opened: \(safePath)"
|
||||
case .failedToStartPlayback:
|
||||
return "Playback could not be started."
|
||||
case .seekUnavailable:
|
||||
@@ -28,3 +38,14 @@ public enum PlaybackError: Error, LocalizedError, Equatable, Hashable, Sendable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PlaybackError {
|
||||
static func safeDisplayPath(from path: String) -> String {
|
||||
let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedPath.isEmpty else {
|
||||
return ""
|
||||
}
|
||||
|
||||
return String(trimmedPath)
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import VelodyPlayback
|
||||
|
||||
@MainActor
|
||||
final class AVFoundationPlaybackEngineTests: XCTestCase {
|
||||
func testLoadTrackMissingFileThrowsPlaybackErrorAndLeavesEngineUnloaded() {
|
||||
let engine = AVFoundationPlaybackEngine(fileManager: .default)
|
||||
let fileURL = URL(fileURLWithPath: "/private/tmp/\(UUID().uuidString)-missing.mp3")
|
||||
|
||||
XCTAssertThrowsError(try engine.loadTrack(at: fileURL, startTime: 0)) { error in
|
||||
XCTAssertEqual(
|
||||
error as? PlaybackError,
|
||||
.missingLocalFile(path: fileURL.path)
|
||||
)
|
||||
}
|
||||
|
||||
XCTAssertEqual(engine.currentTime, 0)
|
||||
XCTAssertEqual(engine.duration, 0)
|
||||
XCTAssertFalse(engine.isPlaying)
|
||||
|
||||
XCTAssertThrowsError(try engine.play()) { error in
|
||||
XCTAssertEqual(error as? PlaybackError, .noTrackLoaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,76 @@ final class PlaybackControllerTests: XCTestCase {
|
||||
XCTAssertTrue(controller.nowPlayingState.isPlaying)
|
||||
XCTAssertEqual(controller.nowPlayingState.currentTime, 0)
|
||||
}
|
||||
|
||||
func testPlayMissingFileCapturesPlaybackErrorWithoutStartingPlayback() {
|
||||
let engine = FakePlaybackEngine()
|
||||
let sessionStore = InMemoryPlaybackSessionStore()
|
||||
let controller = PlaybackController(
|
||||
engine: engine,
|
||||
sessionStore: sessionStore
|
||||
)
|
||||
let track = LibraryTrack(
|
||||
id: "missing-track",
|
||||
title: "Missing Track",
|
||||
artist: "Tester",
|
||||
durationSeconds: 180,
|
||||
localFilePath: "/tmp/missing-track.mp3"
|
||||
)
|
||||
|
||||
engine.loadTrackErrorsByPath[track.localFilePath] = .missingLocalFile(path: track.localFilePath)
|
||||
|
||||
controller.setCatalogTracks([track])
|
||||
controller.play(trackID: track.id)
|
||||
|
||||
XCTAssertEqual(engine.loadTrackCallCount, 1)
|
||||
XCTAssertEqual(engine.playCallCount, 0)
|
||||
XCTAssertFalse(controller.nowPlayingState.isPlaying)
|
||||
XCTAssertEqual(controller.nowPlayingState.currentTrackID, track.id)
|
||||
XCTAssertEqual(
|
||||
controller.nowPlayingState.error,
|
||||
.missingLocalFile(path: track.localFilePath)
|
||||
)
|
||||
XCTAssertEqual(controller.nowPlayingState.duration, track.durationSeconds)
|
||||
}
|
||||
|
||||
func testMissingFileKeepsQueueStableAndAllowsNextTrackPlayback() {
|
||||
let engine = FakePlaybackEngine()
|
||||
let sessionStore = InMemoryPlaybackSessionStore()
|
||||
let controller = PlaybackController(
|
||||
engine: engine,
|
||||
sessionStore: sessionStore
|
||||
)
|
||||
let missingTrack = LibraryTrack(
|
||||
id: "missing-track",
|
||||
title: "Missing Track",
|
||||
artist: "Tester",
|
||||
durationSeconds: 180,
|
||||
localFilePath: "/tmp/missing-track.mp3"
|
||||
)
|
||||
let nextTrack = LibraryTrack(
|
||||
id: "next-track",
|
||||
title: "Next Track",
|
||||
artist: "Tester",
|
||||
durationSeconds: 90,
|
||||
localFilePath: "/tmp/next-track.mp3"
|
||||
)
|
||||
|
||||
engine.loadTrackErrorsByPath[missingTrack.localFilePath] = .missingLocalFile(
|
||||
path: missingTrack.localFilePath
|
||||
)
|
||||
engine.durationByPath[nextTrack.localFilePath] = 90
|
||||
|
||||
controller.setCatalogTracks([missingTrack, nextTrack])
|
||||
controller.play(trackID: missingTrack.id)
|
||||
controller.next()
|
||||
|
||||
XCTAssertEqual(controller.nowPlayingState.currentTrackID, nextTrack.id)
|
||||
XCTAssertTrue(controller.nowPlayingState.isPlaying)
|
||||
XCTAssertNil(controller.nowPlayingState.error)
|
||||
XCTAssertEqual(controller.nowPlayingState.queueTrackIDs, [missingTrack.id, nextTrack.id])
|
||||
XCTAssertEqual(engine.loadTrackCallCount, 2)
|
||||
XCTAssertEqual(engine.playCallCount, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -48,6 +118,8 @@ private final class FakePlaybackEngine: PlaybackEngine {
|
||||
var currentTime: Double = 0
|
||||
var duration: Double = 120
|
||||
var isPlaying = false
|
||||
var durationByPath: [String: Double] = [:]
|
||||
var loadTrackErrorsByPath: [String: PlaybackError] = [:]
|
||||
|
||||
private(set) var loadTrackCallCount = 0
|
||||
private(set) var playCallCount = 0
|
||||
@@ -55,8 +127,17 @@ private final class FakePlaybackEngine: PlaybackEngine {
|
||||
|
||||
func loadTrack(at fileURL: URL, startTime: Double) throws {
|
||||
loadTrackCallCount += 1
|
||||
|
||||
if let error = loadTrackErrorsByPath[fileURL.path] {
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
isPlaying = false
|
||||
throw error
|
||||
}
|
||||
|
||||
lastLoadedStartTime = startTime
|
||||
currentTime = startTime
|
||||
duration = durationByPath[fileURL.path] ?? duration
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import XCTest
|
||||
@testable import VelodyPlayback
|
||||
|
||||
final class PlaybackErrorTests: XCTestCase {
|
||||
func testMissingLocalFileErrorDescriptionIncludesPath() {
|
||||
let error = PlaybackError.missingLocalFile(path: "/tmp/missing-track.mp3")
|
||||
|
||||
XCTAssertEqual(
|
||||
error.errorDescription,
|
||||
"The local file could not be found: /tmp/missing-track.mp3"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
error.localizedDescription,
|
||||
"The local file could not be found: /tmp/missing-track.mp3"
|
||||
)
|
||||
}
|
||||
|
||||
func testMissingLocalFileErrorDescriptionFallsBackForBlankPath() {
|
||||
let error = PlaybackError.missingLocalFile(path: " ")
|
||||
|
||||
XCTAssertEqual(
|
||||
error.errorDescription,
|
||||
"The local file could not be found."
|
||||
)
|
||||
XCTAssertEqual(
|
||||
error.localizedDescription,
|
||||
"The local file could not be found."
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user