Implement iPhone now playing experience

This commit is contained in:
diyaa
2026-06-01 00:24:16 +02:00
parent 3d7252ce6d
commit a147e98b21
7 changed files with 1524 additions and 549 deletions
@@ -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
}