Add iPhone local favorites
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user