Implement iPhone now playing experience
This commit is contained in:
@@ -286,166 +286,3 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
|
||||
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TestPlayer: iPhoneLocalAudioPlaying {
|
||||
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
|
||||
private(set) var state = iPhoneNowPlayingState(
|
||||
trackID: nil,
|
||||
title: nil,
|
||||
artist: nil,
|
||||
isPlaying: false,
|
||||
errorMessage: nil
|
||||
)
|
||||
|
||||
func play(
|
||||
trackID: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
fileURL: URL
|
||||
) throws {
|
||||
_ = fileURL
|
||||
state = iPhoneNowPlayingState(
|
||||
trackID: trackID,
|
||||
title: title,
|
||||
artist: artist,
|
||||
isPlaying: true,
|
||||
errorMessage: nil
|
||||
)
|
||||
onStateChange?(state)
|
||||
}
|
||||
|
||||
func resume() throws {
|
||||
state.isPlaying = true
|
||||
state.errorMessage = nil
|
||||
onStateChange?(state)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
state.isPlaying = false
|
||||
onStateChange?(state)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
private func makeViewModel(
|
||||
remoteTracks: [RemoteTrack],
|
||||
downloadStates: [RemoteTrackDownloadState] = [],
|
||||
favoriteTrackStore: any FavoriteTrackStore = InMemoryFavoriteTrackStore(),
|
||||
audioFiles: [String: Data] = [:],
|
||||
player: (any iPhoneLocalAudioPlaying)? = nil
|
||||
) -> iPhoneLibraryViewModel {
|
||||
let repository = TestRemoteLibraryRepository(tracks: remoteTracks)
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: downloadStates)
|
||||
let audioFileStore = InMemoryOfflineAudioFileStore(files: audioFiles)
|
||||
let artworkStore = InMemoryArtworkStore()
|
||||
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: URLSessionVelodyAPIClient(
|
||||
environment: ServerEnvironment(
|
||||
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
||||
appVersion: "Tests"
|
||||
)
|
||||
),
|
||||
syncService: syncService,
|
||||
offlineLibraryService: offlineLibraryService,
|
||||
favoriteTrackStore: favoriteTrackStore,
|
||||
player: player ?? TestPlayer(),
|
||||
keychainService: MemoryKeychainService()
|
||||
)
|
||||
}
|
||||
|
||||
private func makeRemoteTrack(
|
||||
trackId: String,
|
||||
assetId: String,
|
||||
title: String
|
||||
) -> 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"
|
||||
)
|
||||
}
|
||||
|
||||
private func makeDownloadedState(for track: RemoteTrack) -> RemoteTrackDownloadState {
|
||||
RemoteTrackDownloadState(
|
||||
remoteTrackId: track.trackId,
|
||||
assetId: track.assetId,
|
||||
localFilePath: localFilePath(for: track),
|
||||
downloadedAt: Date(timeIntervalSince1970: 1_000),
|
||||
downloadStatus: .downloaded
|
||||
)
|
||||
}
|
||||
|
||||
private func localFilePath(for track: RemoteTrack) -> String {
|
||||
"/in-memory/\(track.assetId).mp3"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func remoteRow(
|
||||
in viewModel: iPhoneLibraryViewModel,
|
||||
trackID: String
|
||||
) -> RemoteTrackRowViewData? {
|
||||
viewModel.remoteTracks.first(where: { $0.id == trackID })
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func offlineRow(
|
||||
in viewModel: iPhoneLibraryViewModel,
|
||||
trackID: String
|
||||
) -> AvailableOfflineTrackRowViewData? {
|
||||
viewModel.availableOfflineTracks.first(where: { $0.id == trackID })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
import VelodyDomain
|
||||
import VelodyPlayback
|
||||
import VelodyPersistence
|
||||
@testable import VelodyiPhone
|
||||
|
||||
@MainActor
|
||||
final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
|
||||
func testNowPlayingCardShowsTitleArtistAndArtwork() async throws {
|
||||
let artwork = RemoteArtwork(
|
||||
artworkId: "artwork-midnight",
|
||||
sha256: String(repeating: "b", count: 64),
|
||||
mimeType: "image/png"
|
||||
)
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "remote-midnight-city",
|
||||
assetId: "asset-midnight-city",
|
||||
title: "Midnight City",
|
||||
artwork: artwork
|
||||
)
|
||||
let player = TestPlayer()
|
||||
let viewModel = makeViewModel(
|
||||
remoteTracks: [track],
|
||||
downloadStates: [makeDownloadedState(for: track)],
|
||||
audioFiles: [localFilePath(for: track): Data([0x1, 0x2])],
|
||||
artworkPayloadsByArtworkID: [artwork.artworkId: Data([0x9, 0x8])],
|
||||
player: player
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
viewModel.togglePlayback(trackID: track.trackId)
|
||||
|
||||
let card = try XCTUnwrap(viewModel.nowPlayingCard)
|
||||
XCTAssertEqual(card.title, track.title)
|
||||
XCTAssertEqual(card.artist, track.artist)
|
||||
XCTAssertEqual(card.artworkLocalFilePath, localArtworkFilePath(for: artwork))
|
||||
XCTAssertEqual(card.downloadBadge, .downloaded)
|
||||
XCTAssertEqual(card.playbackStateText, "Playing")
|
||||
}
|
||||
|
||||
func testMissingLocalFilePreventsPlayingAndDisablesSeek() async throws {
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "remote-missing-file",
|
||||
assetId: "asset-missing-file",
|
||||
title: "Lost Signal"
|
||||
)
|
||||
let player = TestPlayer()
|
||||
player.missingTrackIDs.insert(track.trackId)
|
||||
let viewModel = makeViewModel(
|
||||
remoteTracks: [track],
|
||||
downloadStates: [makeDownloadedState(for: track)],
|
||||
player: player
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
viewModel.togglePlayback(trackID: track.trackId)
|
||||
|
||||
let card = try XCTUnwrap(viewModel.nowPlayingCard)
|
||||
XCTAssertEqual(viewModel.nowPlaying.playbackState, .missingFile)
|
||||
XCTAssertFalse(viewModel.nowPlaying.isPlaying)
|
||||
XCTAssertFalse(card.canSeek)
|
||||
XCTAssertEqual(card.downloadBadge, .missing)
|
||||
XCTAssertEqual(card.title, track.title)
|
||||
XCTAssertEqual(card.artist, track.artist)
|
||||
XCTAssertEqual(card.playbackStateText, "Missing file")
|
||||
XCTAssertEqual(card.errorMessage, "The local file could not be found: \(localFilePath(for: track))")
|
||||
}
|
||||
|
||||
func testPauseStopsProgressUpdates() async throws {
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "remote-pause",
|
||||
assetId: "asset-pause",
|
||||
title: "Pause Study"
|
||||
)
|
||||
let player = TestPlayer()
|
||||
let viewModel = makeViewModel(
|
||||
remoteTracks: [track],
|
||||
downloadStates: [makeDownloadedState(for: track)],
|
||||
audioFiles: [localFilePath(for: track): Data([0x1])],
|
||||
player: player
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
viewModel.togglePlayback(trackID: track.trackId)
|
||||
player.advanceProgress(by: 12)
|
||||
|
||||
XCTAssertEqual(viewModel.nowPlaying.currentTime, 12)
|
||||
|
||||
viewModel.togglePlayback(trackID: track.trackId)
|
||||
player.advanceProgress(by: 8)
|
||||
|
||||
XCTAssertEqual(viewModel.nowPlaying.playbackState, .paused)
|
||||
XCTAssertEqual(viewModel.nowPlaying.currentTime, 12)
|
||||
}
|
||||
|
||||
func testStopResetsProgressSafely() async throws {
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "remote-stop",
|
||||
assetId: "asset-stop",
|
||||
title: "Stop Motion"
|
||||
)
|
||||
let player = TestPlayer()
|
||||
let viewModel = makeViewModel(
|
||||
remoteTracks: [track],
|
||||
downloadStates: [makeDownloadedState(for: track)],
|
||||
audioFiles: [localFilePath(for: track): Data([0x1])],
|
||||
player: player
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
viewModel.togglePlayback(trackID: track.trackId)
|
||||
player.advanceProgress(by: 33)
|
||||
|
||||
viewModel.stopPlayback()
|
||||
|
||||
XCTAssertEqual(viewModel.nowPlaying.playbackState, .stopped)
|
||||
XCTAssertEqual(viewModel.nowPlaying.currentTime, 0)
|
||||
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
|
||||
}
|
||||
|
||||
func testSearchDoesNotClearNowPlaying() async throws {
|
||||
let firstTrack = makeRemoteTrack(
|
||||
trackId: "remote-first-search",
|
||||
assetId: "asset-first-search",
|
||||
title: "Blue Avenue"
|
||||
)
|
||||
let secondTrack = makeRemoteTrack(
|
||||
trackId: "remote-second-search",
|
||||
assetId: "asset-second-search",
|
||||
title: "Golden Hour"
|
||||
)
|
||||
let player = TestPlayer()
|
||||
let viewModel = makeViewModel(
|
||||
remoteTracks: [firstTrack, secondTrack],
|
||||
downloadStates: [
|
||||
makeDownloadedState(for: firstTrack),
|
||||
makeDownloadedState(for: secondTrack),
|
||||
],
|
||||
audioFiles: [
|
||||
localFilePath(for: firstTrack): Data([0x1]),
|
||||
localFilePath(for: secondTrack): Data([0x2]),
|
||||
],
|
||||
player: player
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
viewModel.togglePlayback(trackID: firstTrack.trackId)
|
||||
viewModel.searchText = secondTrack.title
|
||||
|
||||
let card = try XCTUnwrap(viewModel.nowPlayingCard)
|
||||
XCTAssertEqual(card.trackID, firstTrack.trackId)
|
||||
XCTAssertNil(remoteRow(in: viewModel, trackID: firstTrack.trackId))
|
||||
XCTAssertEqual(viewModel.remoteTracks.count, 1)
|
||||
}
|
||||
|
||||
func testPlaybackErrorDoesNotLeaveStateAsPlaying() async throws {
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "remote-failure",
|
||||
assetId: "asset-failure",
|
||||
title: "Hard Stop"
|
||||
)
|
||||
let player = TestPlayer()
|
||||
player.failingTrackIDs.insert(track.trackId)
|
||||
let viewModel = makeViewModel(
|
||||
remoteTracks: [track],
|
||||
downloadStates: [makeDownloadedState(for: track)],
|
||||
audioFiles: [localFilePath(for: track): Data([0x1])],
|
||||
player: player
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
viewModel.togglePlayback(trackID: track.trackId)
|
||||
|
||||
XCTAssertEqual(viewModel.nowPlaying.playbackState, .failed)
|
||||
XCTAssertFalse(viewModel.nowPlaying.isPlaying)
|
||||
XCTAssertEqual(viewModel.nowPlaying.errorMessage, "Playback could not be started.")
|
||||
}
|
||||
|
||||
func testSeekUpdatesCurrentTimeWhenTrackIsLoaded() async throws {
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "remote-seek",
|
||||
assetId: "asset-seek",
|
||||
title: "Seeklight"
|
||||
)
|
||||
let player = TestPlayer()
|
||||
let viewModel = makeViewModel(
|
||||
remoteTracks: [track],
|
||||
downloadStates: [makeDownloadedState(for: track)],
|
||||
audioFiles: [localFilePath(for: track): Data([0x1])],
|
||||
player: player
|
||||
)
|
||||
|
||||
await viewModel.loadIfNeeded()
|
||||
viewModel.togglePlayback(trackID: track.trackId)
|
||||
viewModel.seekPlayback(to: 90)
|
||||
|
||||
XCTAssertEqual(viewModel.nowPlaying.currentTime, 90)
|
||||
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
|
||||
}
|
||||
|
||||
func testRelaunchRestoresMetadataButDoesNotAutoplay() {
|
||||
let suiteName = "de.diyaa.velody.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
let sessionStore = iPhonePlaybackSessionStore(
|
||||
userDefaults: defaults,
|
||||
storageKey: "playback"
|
||||
)
|
||||
let track = LibraryTrack(
|
||||
id: "remote-restore",
|
||||
title: "Restore Point",
|
||||
artist: "Velody Artist",
|
||||
durationSeconds: 245,
|
||||
localFilePath: "/in-memory/asset-restore.mp3",
|
||||
remoteTrackId: "remote-restore"
|
||||
)
|
||||
|
||||
let firstEngine = FakePlaybackEngine()
|
||||
firstEngine.durationByPath[track.localFilePath] = 245
|
||||
let firstPlayer = iPhonePlaybackControllerPlayer(
|
||||
controller: PlaybackController(
|
||||
engine: firstEngine,
|
||||
sessionStore: sessionStore
|
||||
)
|
||||
)
|
||||
firstPlayer.setCatalogTracks([track])
|
||||
firstPlayer.play(trackID: track.id)
|
||||
firstEngine.currentTime = 48
|
||||
firstPlayer.pause()
|
||||
|
||||
let storedSession = sessionStore.loadSession()
|
||||
XCTAssertEqual(storedSession?.currentTrackID, track.id)
|
||||
XCTAssertEqual(storedSession?.currentTime, 48)
|
||||
XCTAssertEqual(storedSession?.queueTrackIDs, [])
|
||||
|
||||
let secondEngine = FakePlaybackEngine()
|
||||
secondEngine.durationByPath[track.localFilePath] = 245
|
||||
let relaunchedPlayer = iPhonePlaybackControllerPlayer(
|
||||
controller: PlaybackController(
|
||||
engine: secondEngine,
|
||||
sessionStore: sessionStore
|
||||
)
|
||||
)
|
||||
relaunchedPlayer.setCatalogTracks([track])
|
||||
|
||||
XCTAssertEqual(relaunchedPlayer.state.trackID, track.id)
|
||||
XCTAssertEqual(relaunchedPlayer.state.title, track.title)
|
||||
XCTAssertEqual(relaunchedPlayer.state.artist, track.artist)
|
||||
XCTAssertEqual(relaunchedPlayer.state.currentTime, 48)
|
||||
XCTAssertEqual(relaunchedPlayer.state.playbackState, .paused)
|
||||
XCTAssertFalse(relaunchedPlayer.state.isPlaying)
|
||||
XCTAssertNil(relaunchedPlayer.state.errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakePlaybackEngine: PlaybackEngine {
|
||||
var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)?
|
||||
var currentTime: Double = 0
|
||||
var duration: Double = 0
|
||||
var isPlaying = false
|
||||
var durationByPath: [String: Double] = [:]
|
||||
|
||||
func loadTrack(at fileURL: URL, startTime: Double) throws {
|
||||
currentTime = startTime
|
||||
duration = durationByPath[fileURL.path] ?? 0
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func play() throws {
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
func pause() {
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isPlaying = false
|
||||
currentTime = 0
|
||||
}
|
||||
|
||||
func seek(to time: Double) throws {
|
||||
currentTime = min(max(time, 0), duration)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
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
|
||||
) -> 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: URLSessionVelodyAPIClient(
|
||||
environment: ServerEnvironment(
|
||||
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
||||
appVersion: "Tests"
|
||||
)
|
||||
),
|
||||
syncService: syncService,
|
||||
offlineLibraryService: offlineLibraryService,
|
||||
favoriteTrackStore: favoriteTrackStore,
|
||||
player: player ?? TestPlayer(),
|
||||
keychainService: MemoryKeychainService()
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user