Implement remote library metadata sync for iPhone
This commit is contained in:
@@ -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))"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user