import Foundation import Observation import VelodyDomain import VelodyNetworking import VelodyPlayback import VelodyPersistence import VelodySync import VelodyUtilities #if canImport(UIKit) import UIKit #endif @MainActor protocol iPhoneLocalAudioPlaying: AnyObject { var onStateChange: ((iPhoneNowPlayingState) -> Void)? { get set } var state: iPhoneNowPlayingState { get } func setCatalogTracks(_ tracks: [LibraryTrack]) func play(trackID: String) func pause() func stop() func seek(to time: Double) } enum iPhonePlaybackState: Equatable { case playing case paused case stopped case missingFile case failed var displayText: String { switch self { case .playing: return "Playing" case .paused: return "Paused" case .stopped: return "Stopped" case .missingFile: return "Missing file" case .failed: return "Failed" } } } struct iPhoneNowPlayingState: Equatable { var trackID: String? var title: String? var artist: String? var playbackState: iPhonePlaybackState var currentTime: Double var duration: Double var errorMessage: String? static let empty = iPhoneNowPlayingState( trackID: nil, title: nil, artist: nil, playbackState: .stopped, currentTime: 0, duration: 0, errorMessage: nil ) var isPlaying: Bool { playbackState == .playing } var hasTrack: Bool { trackID != nil } var canSeek: Bool { hasTrack && duration > 0 && playbackState != .missingFile && playbackState != .failed } } private struct StorediPhonePlaybackSession: Codable { var currentTrackID: String? var currentTime: Double } struct iPhonePlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable { private let userDefaults: UserDefaults private let storageKey: String private let encoder = JSONEncoder() private let decoder = JSONDecoder() init( userDefaults: UserDefaults = .standard, storageKey: String = "velody.iphone.playback.session" ) { self.userDefaults = userDefaults self.storageKey = storageKey } func loadSession() -> PlaybackSessionSnapshot? { guard let data = userDefaults.data(forKey: storageKey), let storedSession = try? decoder.decode(StorediPhonePlaybackSession.self, from: data) else { return nil } return PlaybackSessionSnapshot( queueTrackIDs: [], currentTrackID: storedSession.currentTrackID, currentTime: storedSession.currentTime, isShuffleEnabled: false, repeatMode: .off ) } func saveSession(_ session: PlaybackSessionSnapshot) { guard let currentTrackID = session.currentTrackID, !currentTrackID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { clearSession() return } let storedSession = StorediPhonePlaybackSession( currentTrackID: currentTrackID, currentTime: session.currentTime ) guard let data = try? encoder.encode(storedSession) else { return } userDefaults.set(data, forKey: storageKey) } func clearSession() { userDefaults.removeObject(forKey: storageKey) } } @MainActor final class iPhonePlaybackControllerPlayer: iPhoneLocalAudioPlaying { var onStateChange: ((iPhoneNowPlayingState) -> Void)? private(set) var state: iPhoneNowPlayingState = .empty { didSet { onStateChange?(state) } } private let controller: PlaybackController private var idleStateHint: iPhonePlaybackState = .stopped init( controller: PlaybackController? = nil ) { self.controller = controller ?? PlaybackController( sessionStore: iPhonePlaybackSessionStore() ) self.state = Self.makeState( from: self.controller.nowPlayingState, idleStateHint: .stopped ) self.controller.onStateChange = { [weak self] state in self?.apply(state) } } func setCatalogTracks(_ tracks: [LibraryTrack]) { controller.setCatalogTracks(tracks) apply(controller.nowPlayingState) } func play(trackID: String) { idleStateHint = .paused controller.play(trackID: trackID) apply(controller.nowPlayingState) } func pause() { idleStateHint = .paused controller.pause() apply(controller.nowPlayingState) } func stop() { idleStateHint = .stopped controller.stop() apply(controller.nowPlayingState) } func seek(to time: Double) { controller.seek(to: time) apply(controller.nowPlayingState) } private func apply(_ nowPlayingState: NowPlayingState) { let effectiveDuration = max( nowPlayingState.duration, nowPlayingState.currentTrack?.durationSeconds ?? 0 ) if nowPlayingState.isPlaying { idleStateHint = .paused } else if nowPlayingState.currentTrack == nil { idleStateHint = .stopped } else if effectiveDuration > 0, nowPlayingState.currentTime >= max(effectiveDuration - 0.25, 0) { idleStateHint = .stopped } state = Self.makeState( from: nowPlayingState, idleStateHint: idleStateHint ) } private static func makeState( from nowPlayingState: NowPlayingState, idleStateHint: iPhonePlaybackState ) -> iPhoneNowPlayingState { let effectiveDuration = max( nowPlayingState.duration, nowPlayingState.currentTrack?.durationSeconds ?? 0 ) let playbackState: iPhonePlaybackState if let playbackError = nowPlayingState.error { switch playbackError { case .missingLocalFile: playbackState = .missingFile default: playbackState = .failed } } else if nowPlayingState.isPlaying { playbackState = .playing } else if nowPlayingState.currentTrack != nil { if nowPlayingState.currentTime > 0, effectiveDuration > 0, nowPlayingState.currentTime < effectiveDuration { playbackState = .paused } else { playbackState = idleStateHint } } else { playbackState = .stopped } return iPhoneNowPlayingState( trackID: nowPlayingState.currentTrackID, title: nowPlayingState.currentTrack?.title, artist: nowPlayingState.currentTrack?.artist, playbackState: playbackState, currentTime: nowPlayingState.currentTime, duration: effectiveDuration, errorMessage: nowPlayingState.error?.localizedDescription ) } } enum NowPlayingDownloadBadge: Equatable { case downloaded case missing case offline var title: String { switch self { case .downloaded: return "Downloaded" case .missing: return "Missing" case .offline: return "Offline" } } } struct NowPlayingCardViewData: Equatable { let trackID: String let title: String let artist: String let artworkLocalFilePath: String? let playbackStateText: String let downloadBadge: NowPlayingDownloadBadge let currentTime: Double let duration: Double let currentTimeText: String let durationText: String let progress: Double let isPlaying: Bool let canSeek: Bool let errorMessage: String? } @MainActor @Observable final class iPhoneLibraryViewModel { enum ViewState: Equatable { case idle case loading case success case empty case networkError(String) } var remoteTracks: [RemoteTrackRowViewData] = [] var availableOfflineTracks: [AvailableOfflineTrackRowViewData] = [] var searchText = "" { didSet { guard searchText != oldValue else { return } rebuildRows() } } var syncStatus = "Remote library not synced yet." var state: ViewState = .idle var nowPlaying: iPhoneNowPlayingState = .empty 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 { !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var hasCachedRemoteTracks: Bool { !cachedRemoteLibraryTracks.isEmpty } var nowPlayingCard: NowPlayingCardViewData? { guard let trackID = nowPlaying.trackID, let title = nowPlaying.title else { return nil } let remoteTrack = cachedRemoteLibraryTracks.first(where: { $0.remoteTrack.trackId == trackID }) let fallbackOfflineTrack = cachedAvailableOfflineTracks.first(where: { $0.remoteTrackId == trackID }) let artist = nowPlaying.artist ?? remoteTrack?.remoteTrack.artist ?? fallbackOfflineTrack?.artist ?? "Unknown Artist" let duration = max( nowPlaying.duration, Double(remoteTrack?.remoteTrack.durationSeconds ?? fallbackOfflineTrack?.durationSeconds ?? 0) ) let clampedCurrentTime: Double if duration > 0 { clampedCurrentTime = min(max(nowPlaying.currentTime, 0), duration) } else { clampedCurrentTime = max(nowPlaying.currentTime, 0) } let progress: Double if duration > 0 { progress = min(max(clampedCurrentTime / duration, 0), 1) } else { progress = 0 } return NowPlayingCardViewData( trackID: trackID, title: title, artist: artist, artworkLocalFilePath: remoteTrack?.localArtworkFilePath ?? fallbackOfflineTrack?.localArtworkFilePath, playbackStateText: nowPlaying.playbackState.displayText, downloadBadge: nowPlayingDownloadBadge(for: trackID), currentTime: clampedCurrentTime, duration: duration, currentTimeText: Self.formatPlaybackTime(clampedCurrentTime), durationText: Self.formatPlaybackTime(duration), progress: progress, isPlaying: nowPlaying.isPlaying, canSeek: nowPlaying.canSeek && duration > 0, errorMessage: nowPlaying.errorMessage ) } convenience init( player: (any iPhoneLocalAudioPlaying)? = nil, keychainService: any KeychainService = SystemKeychainService( service: "de.diyaa.velody.iphone" ) ) { let environment = ServerEnvironment( baseURL: ServerEnvironment.defaultLocalBaseURL, appVersion: "0.1.0" ) let apiClient = URLSessionVelodyAPIClient(environment: environment) let store = Self.makeRemoteLibraryStore() let downloadStateStore = Self.makeRemoteTrackDownloadStateStore() let audioFileStore = Self.makeOfflineAudioFileStore() let artworkStore = Self.makeArtworkStore() let favoriteTrackStore = Self.makeFavoriteTrackStore() let repository = DefaultRemoteLibraryRepository( apiClient: apiClient, store: store ) let syncService = RemoteLibrarySyncService( repository: repository, downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, artworkStore: artworkStore ) let offlineLibraryService = OfflineLibraryService( syncService: syncService, audioFileStore: audioFileStore, artworkStore: artworkStore ) self.init( environment: environment, apiClient: apiClient, syncService: syncService, offlineLibraryService: offlineLibraryService, favoriteTrackStore: favoriteTrackStore, player: player ?? iPhonePlaybackControllerPlayer(), 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.nowPlaying = player.state self.player.onStateChange = { [weak self] state in self?.handleNowPlayingStateChange(state) } } func loadIfNeeded() async { 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)" } } func refreshSync() async { state = .loading syncStatus = "Syncing remote library..." do { let deviceId = try await currentOrRegisterDeviceID() _ = try await syncService.syncRemoteLibrary(deviceId: deviceId) let snapshot = try await reloadOfflineLibrary() applySyncedTracks(snapshot) } catch { state = .networkError("Remote library sync failed.") syncStatus = "Remote library sync failed: \(error.localizedDescription)" } } func downloadTrack(trackID: String) async { guard let track = cachedRemoteTracksByID[trackID] else { return } updateRemoteTrack(trackID: trackID) { remoteTrack in var updatedTrack = remoteTrack updatedTrack.status = .downloading updatedTrack.lastDownloadError = nil return updatedTrack } rebuildRows() syncStatus = "Downloading \(track.title)..." do { let deviceId = try await currentOrRegisterDeviceID() _ = try await syncService.downloadTrack(track, deviceId: deviceId) _ = try await reloadOfflineLibrary() syncStatus = "Downloaded \(track.title)." } catch { _ = try? await reloadOfflineLibrary() syncStatus = "Download failed for \(track.title): \(error.localizedDescription)" } } func togglePlayback(trackID: String) { guard let track = cachedRemoteTracksByID[trackID] else { return } if nowPlaying.trackID == track.trackId, nowPlaying.isPlaying { player.pause() return } guard canAttemptPlayback(for: track.trackId) else { if remoteTrackStatus(for: track.trackId) == .missing { syncStatus = "The downloaded file for \(track.title) is missing." } else { syncStatus = "Download the track before playing it offline." } return } player.play(trackID: track.trackId) handlePlaybackResult(for: track) } func stopPlayback() { player.stop() } func seekPlayback(to time: Double) { player.seek(to: time) } 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 ), !existingDeviceID.isEmpty { return existingDeviceID } let response = try await apiClient.registerDevice( DeviceRegistrationPayload( platform: .iphone, deviceName: Self.currentDeviceName, appVersion: environment.appVersion ) ) try await keychainService.save(response.deviceId, forKey: Self.deviceIDKey) try await keychainService.save( response.bootstrapToken, forKey: Self.bootstrapTokenKey ) return response.deviceId } private func applyRestoredTracks(_ snapshot: OfflineLibrarySnapshot) { if snapshot.remoteTracks.isEmpty { state = .idle syncStatus = "Tap Sync Remote Library to load remote metadata." } else { state = .success syncStatus = "Restored \(snapshot.remoteTracks.count) cached remote track(s). Offline available: \(snapshot.availableTracks.count)." } } private func applySyncedTracks(_ snapshot: OfflineLibrarySnapshot) { if snapshot.remoteTracks.isEmpty { state = .empty syncStatus = "Remote library is empty." } else { state = .success syncStatus = "Sync Remote Library completed. Remote tracks: \(snapshot.remoteTracks.count). Offline available: \(snapshot.availableTracks.count)." } } private static func makeRemoteLibraryStore() -> any RemoteLibraryStore { if let store = try? FileRemoteLibraryStore() { return store } return InMemoryRemoteLibraryStore() } private static func makeRemoteTrackDownloadStateStore() -> any RemoteTrackDownloadStateStore { if let store = try? FileRemoteTrackDownloadStateStore() { return store } return InMemoryRemoteTrackDownloadStateStore() } private static func makeOfflineAudioFileStore() -> any OfflineAudioFileStore { if let store = try? FileOfflineAudioFileStore() { return store } return InMemoryOfflineAudioFileStore() } private static func makeArtworkStore() -> any ArtworkStore { if let store = try? FileArtworkStore() { return store } 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() cachedRemoteLibraryTracks = snapshot.remoteTracks cachedAvailableOfflineTracks = snapshot.availableTracks cachedRemoteTracksByID = Dictionary( uniqueKeysWithValues: snapshot.remoteTracks.map { ($0.remoteTrack.trackId, $0.remoteTrack) } ) player.setCatalogTracks(Self.makePlaybackCatalog(from: snapshot.remoteTracks)) rebuildRows() return snapshot } private func rebuildRows() { let favoriteTrackIDs = Set(cachedFavoriteTrackRecordsByID.keys) let filteredSnapshot = OfflineLibrarySnapshot( remoteTracks: cachedRemoteLibraryTracks, availableTracks: cachedAvailableOfflineTracks ).filtered(searchText: searchText) remoteTracks = filteredSnapshot.remoteTracks.map { track in RemoteTrackRowViewData( track: track, nowPlaying: nowPlaying, isFavorite: favoriteTrackIDs.contains(track.remoteTrack.trackId) ) } availableOfflineTracks = filteredSnapshot.availableTracks.map { track in AvailableOfflineTrackRowViewData( track: track, 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) { nowPlaying = state rebuildRows() } private func handlePlaybackResult(for track: RemoteTrack) { switch nowPlaying.playbackState { case .missingFile: syncStatus = "The downloaded file for \(track.title) is missing." refreshOfflineLibraryInBackground() case .failed: syncStatus = "Playback failed for \(track.title): \(nowPlaying.errorMessage ?? "Unknown error.")" case .playing, .paused, .stopped: break } } private func canAttemptPlayback(for trackID: String) -> Bool { guard let remoteTrack = cachedRemoteLibraryTracks.first(where: { $0.remoteTrack.trackId == trackID }) else { return false } return remoteTrack.isFileAvailable || remoteTrack.status == .missing } private func remoteTrackStatus(for trackID: String) -> OfflineLibraryRemoteTrackStatus? { cachedRemoteLibraryTracks.first(where: { $0.remoteTrack.trackId == trackID })?.status } private func nowPlayingDownloadBadge(for trackID: String) -> NowPlayingDownloadBadge { switch remoteTrackStatus(for: trackID) { case .downloaded: return .downloaded case .missing: return .missing default: return .offline } } private func updateRemoteTrack( trackID: String, transform: (OfflineLibraryRemoteTrack) -> OfflineLibraryRemoteTrack ) { guard let index = cachedRemoteLibraryTracks .firstIndex(where: { $0.remoteTrack.trackId == trackID }) else { return } 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 { return } _ = try? await self.reloadOfflineLibrary() } } private static func makePlaybackCatalog( from tracks: [OfflineLibraryRemoteTrack] ) -> [LibraryTrack] { tracks.map { track in LibraryTrack( id: track.remoteTrack.trackId, title: track.remoteTrack.title, artist: track.remoteTrack.artist, durationSeconds: Double(track.remoteTrack.durationSeconds), localFilePath: track.localFilePath, remoteTrackId: track.remoteTrack.trackId ) } } private static func formatPlaybackTime(_ seconds: Double) -> String { guard seconds.isFinite, seconds > 0 else { return "0:00" } let totalSeconds = Int(seconds.rounded(.towardZero)) let minutes = totalSeconds / 60 let remainingSeconds = totalSeconds % 60 return "\(minutes):\(String(format: "%02d", remainingSeconds))" } #if canImport(UIKit) private static var currentDeviceName: String { UIDevice.current.name } #else private static let currentDeviceName = "Velody iPhone" #endif private static let deviceIDKey = "velody.iphone.device-id" private static let bootstrapTokenKey = "velody.iphone.bootstrap-token" } struct RemoteTrackRowViewData: Identifiable, Equatable { let id: String let title: String let artist: String let durationText: String let isFavorite: Bool let remoteTrackID: String let assetID: String let status: OfflineLibraryRemoteTrackStatus let statusText: String let canDownload: Bool let downloadButtonTitle: String let canPlay: Bool let playButtonTitle: String let lastDownloadError: String? let artworkLocalFilePath: String? init( track: OfflineLibraryRemoteTrack, 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 statusText = Self.statusText(for: track.status) canDownload = track.status == .notDownloaded || track.status == .failed || track.status == .missing downloadButtonTitle = Self.downloadButtonTitle(for: track.status) canPlay = track.isFileAvailable playButtonTitle = nowPlaying.trackID == track.remoteTrack.trackId && nowPlaying.isPlaying ? "Pause" : "Play" lastDownloadError = track.lastDownloadError artworkLocalFilePath = track.localArtworkFilePath } static func formatDuration(seconds: Int) -> String { let minutes = seconds / 60 let remainingSeconds = seconds % 60 return "\(minutes):\(String(format: "%02d", remainingSeconds))" } private static func statusText(for status: OfflineLibraryRemoteTrackStatus) -> String { switch status { case .notDownloaded: return "Not downloaded" case .downloading: return "Downloading" case .downloaded: return "Downloaded" case .missing: return "Missing" case .failed: return "Failed" } } private static func downloadButtonTitle(for status: OfflineLibraryRemoteTrackStatus) -> String { switch status { case .notDownloaded: return "Download" case .downloading, .downloaded: return "Download" case .missing: return "Re-download" case .failed: return "Retry" } } } struct AvailableOfflineTrackRowViewData: Identifiable, Equatable { let id: String let title: String let artist: String let durationText: String let isFavorite: Bool let remoteTrackID: String let assetID: String let playButtonTitle: String let artworkLocalFilePath: String? init( track: OfflineLibraryTrack, 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 ? "Pause" : "Play" artworkLocalFilePath = track.localArtworkFilePath } }