velody/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift

172 lines
5.1 KiB
Swift

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))"
}
}