import Foundation import VelodyDomain import VelodyNetworking import VelodyPlayback import VelodyPersistence import VelodySync import VelodyUtilities @testable import VelodyiPhone @MainActor final class TestPlayer: iPhoneLocalAudioPlaying { var onStateChange: ((iPhoneNowPlayingState) -> Void)? private(set) var state: iPhoneNowPlayingState = .empty var failingTrackIDs = Set() var missingTrackIDs = Set() private var catalogTracksByID: [String: LibraryTrack] = [:] private var queue = PlaybackQueue() func setCatalogTracks(_ tracks: [LibraryTrack]) { catalogTracksByID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) }) queue.replaceTrackIDs( tracks.map(\.id), currentTrackID: state.trackID, queuedTrackIDs: state.queueTrackIDs ) refreshState() onStateChange?(state) } func play(trackID: String) { let currentTime = state.trackID == trackID ? state.currentTime : 0 startPlayback(trackID: trackID, currentTime: currentTime) } func pause() { guard let currentTrack else { return } state = Self.makeState( for: currentTrack, queue: queue, playbackState: .paused, currentTime: state.currentTime, errorMessage: nil ) onStateChange?(state) } func stop() { guard let currentTrack else { state = Self.makeEmptyState(queue: queue) onStateChange?(state) return } state = Self.makeState( for: currentTrack, queue: queue, playbackState: .stopped, currentTime: 0, errorMessage: nil ) onStateChange?(state) } func seek(to time: Double) { guard let currentTrack else { return } state = Self.makeState( for: currentTrack, queue: queue, playbackState: state.playbackState, currentTime: time, errorMessage: state.errorMessage ) onStateChange?(state) } func previousTrack() { if state.currentTime > 5 { seek(to: 0) return } guard let previousTrackID = queue.moveToPreviousTrack() else { seek(to: 0) return } startPlayback(trackID: previousTrackID, currentTime: 0) } func nextTrack() { guard let nextTrackID = queue.advanceToNextTrack() else { stop() return } startPlayback(trackID: nextTrackID, currentTime: 0) } func toggleShuffle() { queue.toggleShuffle() refreshState() onStateChange?(state) } func cycleRepeatMode() { queue.cycleRepeatMode() refreshState() onStateChange?(state) } func advanceProgress(by timeDelta: Double) { guard state.isPlaying, let currentTrack else { return } let duration = max(currentTrack.durationSeconds ?? state.duration, 0) let updatedTime = min(state.currentTime + timeDelta, duration) let playbackState: iPhonePlaybackState if duration > 0, updatedTime >= duration { playbackState = .stopped } else { playbackState = .playing } state = Self.makeState( for: currentTrack, queue: queue, playbackState: playbackState, currentTime: updatedTime, errorMessage: nil ) onStateChange?(state) } private func startPlayback(trackID: String, currentTime: Double) { guard let track = catalogTracksByID[trackID] else { return } queue.selectTrack(trackID) if missingTrackIDs.contains(trackID) || track.localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { state = Self.makeState( for: track, queue: queue, playbackState: .missingFile, currentTime: currentTime, errorMessage: "The local file could not be found: \(track.localFilePath)" ) } else if failingTrackIDs.contains(trackID) { state = Self.makeState( for: track, queue: queue, playbackState: .failed, currentTime: currentTime, errorMessage: "Playback could not be started." ) } else { state = Self.makeState( for: track, queue: queue, playbackState: .playing, currentTime: currentTime, errorMessage: nil ) } onStateChange?(state) } private var currentTrack: LibraryTrack? { guard let trackID = state.trackID else { return nil } return catalogTracksByID[trackID] } private func refreshState() { guard let currentTrack else { state = Self.makeEmptyState(queue: queue) return } state = Self.makeState( for: currentTrack, queue: queue, playbackState: state.playbackState, currentTime: state.currentTime, errorMessage: state.errorMessage ) } private static func makeState( for track: LibraryTrack, queue: PlaybackQueue, playbackState: iPhonePlaybackState, currentTime: Double, errorMessage: String? ) -> iPhoneNowPlayingState { let duration = max(track.durationSeconds ?? 0, 0) let clampedTime: Double if duration > 0 { clampedTime = min(max(currentTime, 0), duration) } else { clampedTime = max(currentTime, 0) } return iPhoneNowPlayingState( trackID: track.id, title: track.title, artist: track.artist, queueTrackIDs: queue.queuedTrackIDs, isShuffleEnabled: queue.isShuffleEnabled, repeatMode: queue.repeatMode, playbackState: playbackState, currentTime: clampedTime, duration: duration, errorMessage: errorMessage ) } private static func makeEmptyState(queue: PlaybackQueue) -> iPhoneNowPlayingState { iPhoneNowPlayingState( trackID: nil, title: nil, artist: nil, queueTrackIDs: queue.queuedTrackIDs, isShuffleEnabled: queue.isShuffleEnabled, repeatMode: queue.repeatMode, playbackState: .stopped, currentTime: 0, duration: 0, errorMessage: nil ) } } private actor TestRemoteLibraryRepository: RemoteLibraryRepository { private let tracks: [RemoteTrack] init(tracks: [RemoteTrack]) { self.tracks = tracks } func loadCachedRemoteTracks() async throws -> [RemoteTrack] { tracks } func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] { _ = deviceId return tracks } func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data { _ = assetId _ = deviceId throw TestRepositoryError.unexpectedDownload } func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data { _ = artworkId _ = deviceId throw TestRepositoryError.unexpectedDownload } } private enum TestRepositoryError: Error { case unexpectedDownload } @MainActor func makeViewModel( remoteTracks: [RemoteTrack], downloadStates: [RemoteTrackDownloadState] = [], favoriteTrackStore: any FavoriteTrackStore = InMemoryFavoriteTrackStore(), audioFiles: [String: Data] = [:], artworkPayloadsByArtworkID: [String: Data] = [:], player: (any iPhoneLocalAudioPlaying)? = nil, apiClient: (any VelodyAPIClient)? = nil, keychainService: any KeychainService = MemoryKeychainService() ) -> iPhoneLibraryViewModel { let repository = TestRemoteLibraryRepository(tracks: remoteTracks) let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: downloadStates) let audioFileStore = InMemoryOfflineAudioFileStore(files: audioFiles) let artworkStore = InMemoryArtworkStore( files: makeArtworkFiles( from: remoteTracks, artworkPayloadsByArtworkID: artworkPayloadsByArtworkID ) ) let syncService = RemoteLibrarySyncService( repository: repository, downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, artworkStore: artworkStore ) let offlineLibraryService = OfflineLibraryService( syncService: syncService, audioFileStore: audioFileStore, artworkStore: artworkStore ) return iPhoneLibraryViewModel( environment: ServerEnvironment( baseURL: ServerEnvironment.defaultLocalBaseURL, appVersion: "Tests" ), apiClient: apiClient ?? URLSessionVelodyAPIClient( environment: ServerEnvironment( baseURL: ServerEnvironment.defaultLocalBaseURL, appVersion: "Tests" ) ), syncService: syncService, offlineLibraryService: offlineLibraryService, favoriteTrackStore: favoriteTrackStore, player: player ?? TestPlayer(), keychainService: keychainService ) } func makeRemoteTrack( trackId: String, assetId: String, title: String, artwork: RemoteArtwork? = nil ) -> RemoteTrack { RemoteTrack( trackId: trackId, title: title, artist: "Velody Artist", durationSeconds: 245, sha256: String(repeating: "a", count: 64), assetId: assetId, createdAt: "2026-05-30T08:00:00.000Z", updatedAt: "2026-05-30T08:05:00.000Z", artwork: artwork ) } func makeDownloadedState(for track: RemoteTrack) -> RemoteTrackDownloadState { RemoteTrackDownloadState( remoteTrackId: track.trackId, assetId: track.assetId, localFilePath: localFilePath(for: track), downloadedAt: Date(timeIntervalSince1970: 1_000), downloadStatus: .downloaded ) } func localFilePath(for track: RemoteTrack) -> String { "/in-memory/\(track.assetId).mp3" } func localArtworkFilePath(for artwork: RemoteArtwork) -> String { let fileExtension: String switch artwork.mimeType.lowercased() { case "image/jpeg", "image/jpg": fileExtension = "jpg" case "image/png": fileExtension = "png" case "image/webp": fileExtension = "webp" case "image/heic": fileExtension = "heic" case "image/heif": fileExtension = "heif" case "image/gif": fileExtension = "gif" default: fileExtension = "img" } return "/in-memory/\(artwork.artworkId).\(fileExtension)" } @MainActor func remoteRow( in viewModel: iPhoneLibraryViewModel, trackID: String ) -> RemoteTrackRowViewData? { viewModel.remoteTracks.first(where: { $0.id == trackID }) } @MainActor func offlineRow( in viewModel: iPhoneLibraryViewModel, trackID: String ) -> AvailableOfflineTrackRowViewData? { viewModel.availableOfflineTracks.first(where: { $0.id == trackID }) } private func makeArtworkFiles( from remoteTracks: [RemoteTrack], artworkPayloadsByArtworkID: [String: Data] ) -> [String: Data] { var files: [String: Data] = [:] for remoteTrack in remoteTracks { guard let artwork = remoteTrack.artwork, let data = artworkPayloadsByArtworkID[artwork.artworkId] else { continue } files[localArtworkFilePath(for: artwork)] = data } return files }