350 lines
9.7 KiB
Swift
350 lines
9.7 KiB
Swift
import Foundation
|
|
import VelodyDomain
|
|
import VelodyNetworking
|
|
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<String>()
|
|
var missingTrackIDs = Set<String>()
|
|
|
|
private var catalogTracksByID: [String: LibraryTrack] = [:]
|
|
|
|
func setCatalogTracks(_ tracks: [LibraryTrack]) {
|
|
catalogTracksByID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) })
|
|
|
|
guard let trackID = state.trackID,
|
|
let currentTrack = catalogTracksByID[trackID]
|
|
else {
|
|
return
|
|
}
|
|
|
|
state = Self.makeState(
|
|
for: currentTrack,
|
|
playbackState: state.playbackState,
|
|
currentTime: state.currentTime,
|
|
errorMessage: state.errorMessage
|
|
)
|
|
onStateChange?(state)
|
|
}
|
|
|
|
func play(trackID: String) {
|
|
guard let track = catalogTracksByID[trackID] else {
|
|
return
|
|
}
|
|
|
|
let currentTime = state.trackID == trackID ? state.currentTime : 0
|
|
|
|
if missingTrackIDs.contains(trackID) ||
|
|
track.localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
state = Self.makeState(
|
|
for: track,
|
|
playbackState: .missingFile,
|
|
currentTime: currentTime,
|
|
errorMessage: "The local file could not be found: \(track.localFilePath)"
|
|
)
|
|
} else if failingTrackIDs.contains(trackID) {
|
|
state = Self.makeState(
|
|
for: track,
|
|
playbackState: .failed,
|
|
currentTime: currentTime,
|
|
errorMessage: "Playback could not be started."
|
|
)
|
|
} else {
|
|
state = Self.makeState(
|
|
for: track,
|
|
playbackState: .playing,
|
|
currentTime: currentTime,
|
|
errorMessage: nil
|
|
)
|
|
}
|
|
|
|
onStateChange?(state)
|
|
}
|
|
|
|
func pause() {
|
|
guard let currentTrack else {
|
|
return
|
|
}
|
|
|
|
state = Self.makeState(
|
|
for: currentTrack,
|
|
playbackState: .paused,
|
|
currentTime: state.currentTime,
|
|
errorMessage: nil
|
|
)
|
|
onStateChange?(state)
|
|
}
|
|
|
|
func stop() {
|
|
guard let currentTrack else {
|
|
state = .empty
|
|
onStateChange?(state)
|
|
return
|
|
}
|
|
|
|
state = Self.makeState(
|
|
for: currentTrack,
|
|
playbackState: .stopped,
|
|
currentTime: 0,
|
|
errorMessage: nil
|
|
)
|
|
onStateChange?(state)
|
|
}
|
|
|
|
func seek(to time: Double) {
|
|
guard let currentTrack else {
|
|
return
|
|
}
|
|
|
|
state = Self.makeState(
|
|
for: currentTrack,
|
|
playbackState: state.playbackState,
|
|
currentTime: time,
|
|
errorMessage: state.errorMessage
|
|
)
|
|
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,
|
|
playbackState: playbackState,
|
|
currentTime: updatedTime,
|
|
errorMessage: nil
|
|
)
|
|
onStateChange?(state)
|
|
}
|
|
|
|
private var currentTrack: LibraryTrack? {
|
|
guard let trackID = state.trackID else {
|
|
return nil
|
|
}
|
|
|
|
return catalogTracksByID[trackID]
|
|
}
|
|
|
|
private static func makeState(
|
|
for track: LibraryTrack,
|
|
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,
|
|
playbackState: playbackState,
|
|
currentTime: clampedTime,
|
|
duration: duration,
|
|
errorMessage: errorMessage
|
|
)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|