Add iPhone local favorites

This commit is contained in:
diyaa
2026-05-31 13:20:07 +02:00
parent cb556ccf0a
commit 3d7252ce6d
8 changed files with 1103 additions and 14 deletions
@@ -28,10 +28,20 @@ struct iPhoneLibraryView: View {
Spacer()
if let trackID = viewModel.nowPlaying.trackID {
Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") {
viewModel.togglePlayback(trackID: trackID)
HStack(spacing: 12) {
if viewModel.nowPlayingFavoriteTrackID == trackID {
favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) {
Task {
await viewModel.toggleFavorite(trackID: trackID)
}
}
}
Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") {
viewModel.togglePlayback(trackID: trackID)
}
.buttonStyle(.borderedProminent)
}
.buttonStyle(.borderedProminent)
}
}
}
@@ -61,6 +71,12 @@ struct iPhoneLibraryView: View {
Spacer()
favoriteButton(isFavorite: track.isFavorite) {
Task {
await viewModel.toggleFavorite(trackID: track.id)
}
}
Text(track.statusText)
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
@@ -139,6 +155,12 @@ struct iPhoneLibraryView: View {
Spacer()
favoriteButton(isFavorite: track.isFavorite) {
Task {
await viewModel.toggleFavorite(trackID: track.id)
}
}
Button(track.playButtonTitle) {
viewModel.togglePlayback(trackID: track.id)
}
@@ -255,6 +277,19 @@ struct iPhoneLibraryView: View {
return .red
}
}
private func favoriteButton(
isFavorite: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.font(.title3.weight(.semibold))
.foregroundStyle(isFavorite ? .red : .secondary)
}
.buttonStyle(.borderless)
.accessibilityLabel(isFavorite ? "Remove Favorite" : "Add Favorite")
}
}
private struct ArtworkThumbnailView: View {
@@ -194,16 +194,20 @@ final class iPhoneLibraryViewModel {
isPlaying: false,
errorMessage: nil
)
var nowPlayingFavoriteTrackID: String?
var isNowPlayingTrackFavorite = false
private let environment: ServerEnvironment
private let apiClient: any VelodyAPIClient
private let syncService: RemoteLibrarySyncService
private let offlineLibraryService: OfflineLibraryService
private let favoriteTrackStore: any FavoriteTrackStore
private let keychainService: any KeychainService
private let player: any iPhoneLocalAudioPlaying
private var cachedRemoteTracksByID: [String: RemoteTrack] = [:]
private var cachedRemoteLibraryTracks: [OfflineLibraryRemoteTrack] = []
private var cachedAvailableOfflineTracks: [OfflineLibraryTrack] = []
private var cachedFavoriteTrackRecordsByID: [String: FavoriteTrackRecord] = [:]
private var hasLoaded = false
var hasActiveSearch: Bool {
@@ -214,7 +218,7 @@ final class iPhoneLibraryViewModel {
!cachedRemoteLibraryTracks.isEmpty
}
init(
convenience init(
player: (any iPhoneLocalAudioPlaying)? = nil,
keychainService: any KeychainService = SystemKeychainService(
service: "de.diyaa.velody.iphone"
@@ -229,6 +233,7 @@ final class iPhoneLibraryViewModel {
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore()
let artworkStore = Self.makeArtworkStore()
let favoriteTrackStore = Self.makeFavoriteTrackStore()
let repository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store
@@ -240,16 +245,38 @@ final class iPhoneLibraryViewModel {
artworkStore: artworkStore
)
self.environment = environment
self.apiClient = apiClient
self.keychainService = keychainService
self.player = player ?? iPhoneLocalAudioPlayer()
self.syncService = syncService
self.offlineLibraryService = OfflineLibraryService(
let offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
self.init(
environment: environment,
apiClient: apiClient,
syncService: syncService,
offlineLibraryService: offlineLibraryService,
favoriteTrackStore: favoriteTrackStore,
player: player ?? iPhoneLocalAudioPlayer(),
keychainService: keychainService
)
}
init(
environment: ServerEnvironment,
apiClient: any VelodyAPIClient,
syncService: RemoteLibrarySyncService,
offlineLibraryService: OfflineLibraryService,
favoriteTrackStore: any FavoriteTrackStore,
player: any iPhoneLocalAudioPlaying,
keychainService: any KeychainService
) {
self.environment = environment
self.apiClient = apiClient
self.syncService = syncService
self.offlineLibraryService = offlineLibraryService
self.favoriteTrackStore = favoriteTrackStore
self.keychainService = keychainService
self.player = player
self.player.onStateChange = { [weak self] state in
self?.handleNowPlayingStateChange(state)
}
@@ -259,9 +286,20 @@ final class iPhoneLibraryViewModel {
guard !hasLoaded else { return }
hasLoaded = true
var favoriteRestoreError: Error?
do {
try await reloadFavoriteTracks()
} catch {
favoriteRestoreError = error
}
do {
let snapshot = try await reloadOfflineLibrary()
applyRestoredTracks(snapshot)
if let favoriteRestoreError {
syncStatus += " Favorites could not be restored: \(favoriteRestoreError.localizedDescription)"
}
} catch {
state = .idle
syncStatus = "Failed to load cached remote library: \(error.localizedDescription)"
@@ -354,6 +392,32 @@ final class iPhoneLibraryViewModel {
}
}
func toggleFavorite(trackID: String) async {
guard hasTrackInLibrarySnapshot(trackID) else {
return
}
let previousFavorites = cachedFavoriteTrackRecordsByID
if cachedFavoriteTrackRecordsByID[trackID] != nil {
cachedFavoriteTrackRecordsByID.removeValue(forKey: trackID)
} else {
cachedFavoriteTrackRecordsByID[trackID] = FavoriteTrackRecord(
remoteTrackId: trackID,
favoritedAt: Date()
)
}
rebuildRows()
do {
try await favoriteTrackStore.saveFavoriteTracks(Array(cachedFavoriteTrackRecordsByID.values))
} catch {
cachedFavoriteTrackRecordsByID = previousFavorites
rebuildRows()
syncStatus = "Favorite update failed: \(error.localizedDescription)"
}
}
private func currentOrRegisterDeviceID() async throws -> String {
if let existingDeviceID = try await keychainService.loadValue(
forKey: Self.deviceIDKey
@@ -430,6 +494,25 @@ final class iPhoneLibraryViewModel {
return InMemoryArtworkStore()
}
private static func makeFavoriteTrackStore() -> any FavoriteTrackStore {
if let store = try? FileFavoriteTrackStore() {
return store
}
return InMemoryFavoriteTrackStore()
}
private func reloadFavoriteTracks() async throws {
let favoriteTracks = try await favoriteTrackStore.loadFavoriteTracks()
var favoritesByTrackID: [String: FavoriteTrackRecord] = [:]
for favoriteTrack in favoriteTracks {
favoritesByTrackID[favoriteTrack.remoteTrackId] = favoriteTrack
}
cachedFavoriteTrackRecordsByID = favoritesByTrackID
}
private func reloadOfflineLibrary() async throws -> OfflineLibrarySnapshot {
let snapshot = try await offlineLibraryService.loadSnapshot()
@@ -444,6 +527,7 @@ final class iPhoneLibraryViewModel {
}
private func rebuildRows() {
let favoriteTrackIDs = Set(cachedFavoriteTrackRecordsByID.keys)
let filteredSnapshot = OfflineLibrarySnapshot(
remoteTracks: cachedRemoteLibraryTracks,
availableTracks: cachedAvailableOfflineTracks
@@ -452,15 +536,24 @@ final class iPhoneLibraryViewModel {
remoteTracks = filteredSnapshot.remoteTracks.map { track in
RemoteTrackRowViewData(
track: track,
nowPlaying: nowPlaying
nowPlaying: nowPlaying,
isFavorite: favoriteTrackIDs.contains(track.remoteTrack.trackId)
)
}
availableOfflineTracks = filteredSnapshot.availableTracks.map { track in
AvailableOfflineTrackRowViewData(
track: track,
nowPlaying: nowPlaying
nowPlaying: nowPlaying,
isFavorite: favoriteTrackIDs.contains(track.remoteTrackId)
)
}
if let trackID = nowPlaying.trackID, hasTrackInLibrarySnapshot(trackID) {
nowPlayingFavoriteTrackID = trackID
isNowPlayingTrackFavorite = favoriteTrackIDs.contains(trackID)
} else {
nowPlayingFavoriteTrackID = nil
isNowPlayingTrackFavorite = false
}
}
private func handleNowPlayingStateChange(_ state: iPhoneNowPlayingState) {
@@ -481,6 +574,10 @@ final class iPhoneLibraryViewModel {
cachedRemoteLibraryTracks[index] = transform(cachedRemoteLibraryTracks[index])
}
private func hasTrackInLibrarySnapshot(_ trackID: String) -> Bool {
cachedRemoteTracksByID[trackID] != nil
}
private func refreshOfflineLibraryInBackground() {
Task { @MainActor [weak self] in
guard let self else {
@@ -508,6 +605,7 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
let title: String
let artist: String
let durationText: String
let isFavorite: Bool
let remoteTrackID: String
let assetID: String
let status: OfflineLibraryRemoteTrackStatus
@@ -521,12 +619,14 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
init(
track: OfflineLibraryRemoteTrack,
nowPlaying: iPhoneNowPlayingState
nowPlaying: iPhoneNowPlayingState,
isFavorite: Bool
) {
id = track.remoteTrack.trackId
title = track.remoteTrack.title
artist = track.remoteTrack.artist
durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds)
self.isFavorite = isFavorite
remoteTrackID = track.remoteTrack.trackId
assetID = track.remoteTrack.assetId
status = track.status
@@ -581,6 +681,7 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
let title: String
let artist: String
let durationText: String
let isFavorite: Bool
let remoteTrackID: String
let assetID: String
let playButtonTitle: String
@@ -588,12 +689,14 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
init(
track: OfflineLibraryTrack,
nowPlaying: iPhoneNowPlayingState
nowPlaying: iPhoneNowPlayingState,
isFavorite: Bool
) {
id = track.remoteTrackId
title = track.title
artist = track.artist
durationText = RemoteTrackRowViewData.formatDuration(seconds: track.durationSeconds)
self.isFavorite = isFavorite
remoteTrackID = track.remoteTrackId
assetID = track.assetId
playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying
@@ -0,0 +1,451 @@
import Foundation
import XCTest
import VelodyDomain
import VelodyNetworking
import VelodyPersistence
import VelodySync
import VelodyUtilities
@testable import VelodyiPhone
@MainActor
final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
func testFavoritingTrackUpdatesRemoteOfflineAndNowPlayingState() async throws {
let track = makeRemoteTrack(
trackId: "remote-light-trap",
assetId: "asset-light-trap",
title: "Light Trap"
)
let player = TestPlayer()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
audioFiles: [localFilePath(for: track): Data([0x1, 0x2, 0x3])],
player: player
)
await viewModel.loadIfNeeded()
viewModel.togglePlayback(trackID: track.trackId)
XCTAssertEqual(viewModel.nowPlayingFavoriteTrackID, track.trackId)
XCTAssertFalse(viewModel.isNowPlayingTrackFavorite)
await viewModel.toggleFavorite(trackID: track.trackId)
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite)
XCTAssertEqual(viewModel.nowPlayingFavoriteTrackID, track.trackId)
XCTAssertTrue(viewModel.isNowPlayingTrackFavorite)
}
func testUnfavoritingTrackRemovesFavoriteState() async throws {
let track = makeRemoteTrack(
trackId: "remote-harbor-lights",
assetId: "asset-harbor-lights",
title: "Harbor Lights"
)
let favoriteStore = InMemoryFavoriteTrackStore(tracks: [
FavoriteTrackRecord(
remoteTrackId: track.trackId,
favoritedAt: Date(timeIntervalSince1970: 1_000)
),
])
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
favoriteTrackStore: favoriteStore,
audioFiles: [localFilePath(for: track): Data([0x1])]
)
await viewModel.loadIfNeeded()
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
await viewModel.toggleFavorite(trackID: track.trackId)
XCTAssertFalse(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
XCTAssertFalse(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite)
let savedFavorites = try await favoriteStore.loadFavoriteTracks()
XCTAssertTrue(savedFavorites.isEmpty)
}
func testFavoritesPersistAcrossReload() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let fileURL = tempDirectory.appendingPathComponent("favorite-tracks.json")
let track = makeRemoteTrack(
trackId: "remote-night-drive",
assetId: "asset-night-drive",
title: "Night Drive"
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let firstViewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL),
audioFiles: [localFilePath(for: track): Data([0x2])]
)
await firstViewModel.loadIfNeeded()
await firstViewModel.toggleFavorite(trackID: track.trackId)
let relaunchedViewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL),
audioFiles: [localFilePath(for: track): Data([0x2])]
)
await relaunchedViewModel.loadIfNeeded()
XCTAssertTrue(try XCTUnwrap(remoteRow(in: relaunchedViewModel, trackID: track.trackId)).isFavorite)
await relaunchedViewModel.toggleFavorite(trackID: track.trackId)
let secondRelaunchViewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
favoriteTrackStore: try FileFavoriteTrackStore(fileURL: fileURL),
audioFiles: [localFilePath(for: track): Data([0x2])]
)
await secondRelaunchViewModel.loadIfNeeded()
XCTAssertFalse(try XCTUnwrap(remoteRow(in: secondRelaunchViewModel, trackID: track.trackId)).isFavorite)
}
func testMultipleFavoritesAreTrackedIndependently() async throws {
let firstTrack = makeRemoteTrack(
trackId: "remote-first",
assetId: "asset-first",
title: "First Favorite"
)
let secondTrack = makeRemoteTrack(
trackId: "remote-second",
assetId: "asset-second",
title: "Second Favorite"
)
let favoriteStore = InMemoryFavoriteTrackStore()
let viewModel = makeViewModel(
remoteTracks: [firstTrack, secondTrack],
downloadStates: [
makeDownloadedState(for: firstTrack),
makeDownloadedState(for: secondTrack),
],
favoriteTrackStore: favoriteStore,
audioFiles: [
localFilePath(for: firstTrack): Data([0x1]),
localFilePath(for: secondTrack): Data([0x2]),
]
)
await viewModel.loadIfNeeded()
await viewModel.toggleFavorite(trackID: firstTrack.trackId)
await viewModel.toggleFavorite(trackID: secondTrack.trackId)
let savedIDs = try await favoriteStore.loadFavoriteTracks()
.map(\.remoteTrackId)
.sorted()
XCTAssertEqual(savedIDs, [firstTrack.trackId, secondTrack.trackId].sorted())
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: firstTrack.trackId)).isFavorite)
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite)
}
func testToggleFavoriteRepeatedlyLeavesSingleStableRecord() async throws {
let track = makeRemoteTrack(
trackId: "remote-repeat",
assetId: "asset-repeat",
title: "Repeat Toggle"
)
let favoriteStore = InMemoryFavoriteTrackStore()
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
favoriteTrackStore: favoriteStore,
audioFiles: [localFilePath(for: track): Data([0x3])]
)
await viewModel.loadIfNeeded()
for _ in 0..<5 {
await viewModel.toggleFavorite(trackID: track.trackId)
}
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
let savedFavorites = try await favoriteStore.loadFavoriteTracks()
XCTAssertEqual(savedFavorites.count, 1)
}
func testFavoriteStateDerivesCorrectlyInRemoteLibrary() async throws {
let track = makeRemoteTrack(
trackId: "remote-only",
assetId: "asset-only",
title: "Remote Only"
)
let viewModel = makeViewModel(
remoteTracks: [track],
favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [
FavoriteTrackRecord(
remoteTrackId: track.trackId,
favoritedAt: Date(timeIntervalSince1970: 4_000)
),
])
)
await viewModel.loadIfNeeded()
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
}
func testFavoriteStateDerivesCorrectlyInOfflineLibrary() async throws {
let track = makeRemoteTrack(
trackId: "offline-favorite",
assetId: "asset-offline-favorite",
title: "Offline Favorite"
)
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [
FavoriteTrackRecord(
remoteTrackId: track.trackId,
favoritedAt: Date(timeIntervalSince1970: 5_000)
),
]),
audioFiles: [localFilePath(for: track): Data([0x4])]
)
await viewModel.loadIfNeeded()
XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite)
}
func testSearchDoesNotModifyFavoriteState() async throws {
let favoriteTrack = makeRemoteTrack(
trackId: "remote-search-favorite",
assetId: "asset-search-favorite",
title: "Northern Lights"
)
let otherTrack = makeRemoteTrack(
trackId: "remote-search-other",
assetId: "asset-search-other",
title: "Quiet Harbor"
)
let favoriteStore = InMemoryFavoriteTrackStore()
let viewModel = makeViewModel(
remoteTracks: [favoriteTrack, otherTrack],
downloadStates: [
makeDownloadedState(for: favoriteTrack),
makeDownloadedState(for: otherTrack),
],
favoriteTrackStore: favoriteStore,
audioFiles: [
localFilePath(for: favoriteTrack): Data([0x5]),
localFilePath(for: otherTrack): Data([0x6]),
]
)
await viewModel.loadIfNeeded()
await viewModel.toggleFavorite(trackID: favoriteTrack.trackId)
viewModel.searchText = otherTrack.title
XCTAssertNil(remoteRow(in: viewModel, trackID: favoriteTrack.trackId))
viewModel.searchText = favoriteTrack.title
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: favoriteTrack.trackId)).isFavorite)
let savedFavoriteIDs = try await favoriteStore.loadFavoriteTracks().map(\.remoteTrackId)
XCTAssertEqual(savedFavoriteIDs, [favoriteTrack.trackId])
}
func testMissingFileStateDoesNotRemoveFavorite() async throws {
let track = makeRemoteTrack(
trackId: "remote-missing",
assetId: "asset-missing",
title: "Missing Favorite"
)
let viewModel = makeViewModel(
remoteTracks: [track],
downloadStates: [makeDownloadedState(for: track)],
favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [
FavoriteTrackRecord(
remoteTrackId: track.trackId,
favoritedAt: Date(timeIntervalSince1970: 6_000)
),
])
)
await viewModel.loadIfNeeded()
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
XCTAssertEqual(remoteTrack.status, .missing)
XCTAssertTrue(remoteTrack.isFavorite)
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 })
}