import Foundation import Observation import VelodyDomain import VelodyNetworking import VelodyPersistence import VelodySync import VelodyUtilities #if canImport(UIKit) import UIKit #endif @MainActor @Observable final class iPhoneLibraryViewModel { enum ViewState: Equatable { case idle case loading case success case empty case networkError(String) } var remoteTracks: [RemoteTrackRowViewData] = [] var syncStatus = "Remote library not synced yet." var state: ViewState = .idle private let environment: ServerEnvironment private let apiClient: any VelodyAPIClient private let syncService: RemoteLibrarySyncService private let keychainService: any KeychainService private var hasLoaded = false init( 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() self.environment = environment self.apiClient = apiClient self.keychainService = keychainService self.syncService = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: apiClient, store: store ) ) } func loadIfNeeded() async { guard !hasLoaded else { return } hasLoaded = true do { let persistedTracks = try await syncService.loadCachedRemoteTracks() applyRestoredTracks(persistedTracks) } 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() let tracks = try await syncService.syncRemoteLibrary(deviceId: deviceId) applySyncedTracks(tracks) } catch { state = .networkError("Remote library sync failed.") syncStatus = "Remote library sync 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(_ tracks: [RemoteTrack]) { remoteTracks = tracks.map(RemoteTrackRowViewData.init(track:)) if tracks.isEmpty { state = .idle syncStatus = "Tap Sync Remote Library to load remote metadata." } else { state = .success syncStatus = "Restored \(tracks.count) cached remote track(s)." } } private func applySyncedTracks(_ tracks: [RemoteTrack]) { remoteTracks = tracks.map(RemoteTrackRowViewData.init(track:)) if tracks.isEmpty { state = .empty syncStatus = "Remote library is empty." } else { state = .success syncStatus = "Sync Remote Library completed. Remote tracks: \(tracks.count)." } } private static func makeRemoteLibraryStore() -> any RemoteLibraryStore { if let store = try? FileRemoteLibraryStore() { return store } return InMemoryRemoteLibraryStore() } #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 remoteTrackID: String init(track: RemoteTrack) { id = track.trackId title = track.title artist = track.artist durationText = Self.formatDuration(seconds: track.durationSeconds) remoteTrackID = track.trackId } private static func formatDuration(seconds: Int) -> String { let minutes = seconds / 60 let remainingSeconds = seconds % 60 return "\(minutes):\(String(format: "%02d", remainingSeconds))" } }