Implement remote library metadata sync for iPhone

This commit is contained in:
diyaa
2026-05-29 08:54:09 +02:00
parent ebc187f4a1
commit 6e73c1878e
20 changed files with 1379 additions and 48 deletions
@@ -5,31 +5,38 @@ struct iPhoneLibraryView: View {
var body: some View {
NavigationStack {
List(viewModel.tracks) { track in
VStack(alignment: .leading, spacing: 4) {
Text(track.title)
.font(.headline)
Text(track.artist)
.foregroundStyle(.secondary)
List {
Section("Remote tracks: \(viewModel.remoteTracks.count)") {
ForEach(viewModel.remoteTracks) { track in
VStack(alignment: .leading, spacing: 6) {
Text(track.title)
.font(.headline)
Text(track.artist)
.foregroundStyle(.secondary)
Text("Duration: \(track.durationText)")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Remote track ID: \(track.remoteTrackID)")
.font(.caption)
.foregroundStyle(.tertiary)
.textSelection(.enabled)
}
.padding(.vertical, 4)
}
}
}
.overlay {
if viewModel.tracks.isEmpty {
ContentUnavailableView(
"No Local Tracks Yet",
systemImage: "music.note.list",
description: Text("This iPhone target currently exposes the offline catalog shell only.")
)
}
overlayView
}
.navigationTitle("Velody")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Sync") {
Button("Sync Remote Library") {
Task {
await viewModel.refreshSync()
}
}
.disabled(viewModel.state == .loading)
}
}
.safeAreaInset(edge: .bottom) {
@@ -45,4 +52,36 @@ struct iPhoneLibraryView: View {
await viewModel.loadIfNeeded()
}
}
@ViewBuilder
private var overlayView: some View {
switch viewModel.state {
case .idle:
if viewModel.remoteTracks.isEmpty {
ContentUnavailableView(
"No Remote Library Yet",
systemImage: "music.note.list",
description: Text("Tap Sync Remote Library to fetch metadata from the backend.")
)
}
case .loading:
ProgressView("Syncing remote library...")
case .success:
EmptyView()
case .empty:
ContentUnavailableView(
"Empty Remote Library",
systemImage: "music.note.list",
description: Text("The backend returned no remote tracks for this iPhone.")
)
case .networkError(let message):
if viewModel.remoteTracks.isEmpty {
ContentUnavailableView(
"Network Error",
systemImage: "wifi.exclamationmark",
description: Text(message)
)
}
}
}
}
@@ -4,28 +4,52 @@ import VelodyDomain
import VelodyNetworking
import VelodyPersistence
import VelodySync
import VelodyUtilities
#if canImport(UIKit)
import UIKit
#endif
@MainActor
@Observable
final class iPhoneLibraryViewModel {
var tracks: [LibraryTrack] = []
var syncStatus = "Offline library not synced yet"
enum ViewState: Equatable {
case idle
case loading
case success
case empty
case networkError(String)
}
private let store: any LocalLibraryStore
private let syncCoordinator: PlaceholderSyncCoordinator
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() {
let repository = Self.makeTrackRepository()
init(
keychainService: any KeychainService = SystemKeychainService(
service: "de.diyaa.velody.iphone"
)
) {
let environment = ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "0.1.0"
)
let apiClient = StubVelodyAPIClient(environment: environment)
self.store = repository
self.syncCoordinator = PlaceholderSyncCoordinator(
apiClient: apiClient,
store: repository
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
)
)
}
@@ -34,33 +58,114 @@ final class iPhoneLibraryViewModel {
hasLoaded = true
do {
let persistedTracks = try await store.loadTracks()
if !persistedTracks.isEmpty {
tracks = persistedTracks
syncStatus = "Loaded \(persistedTracks.count) cached track(s) from local storage."
}
let persistedTracks = try await syncService.loadCachedRemoteTracks()
applyRestoredTracks(persistedTracks)
} catch {
syncStatus = "Failed to load cached catalog: \(error.localizedDescription)"
state = .idle
syncStatus = "Failed to load cached remote library: \(error.localizedDescription)"
}
await refreshSync()
}
func refreshSync() async {
state = .loading
syncStatus = "Syncing remote library..."
do {
let result = try await syncCoordinator.performInitialSync()
tracks = result.tracks
syncStatus = result.statusMessage
let deviceId = try await currentOrRegisterDeviceID()
let tracks = try await syncService.syncRemoteLibrary(deviceId: deviceId)
applySyncedTracks(tracks)
} catch {
syncStatus = "Sync placeholder failed: \(error.localizedDescription)"
state = .networkError("Remote library sync failed.")
syncStatus = "Remote library sync failed: \(error.localizedDescription)"
}
}
private static func makeTrackRepository() -> any TrackRepository {
if let repository = try? SwiftDataTrackRepository() {
return repository
private func currentOrRegisterDeviceID() async throws -> String {
if let existingDeviceID = try await keychainService.loadValue(
forKey: Self.deviceIDKey
), !existingDeviceID.isEmpty {
return existingDeviceID
}
return InMemoryTrackRepository()
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))"
}
}