Implement offline library snapshot

This commit is contained in:
diyaa
2026-05-30 08:45:13 +02:00
parent 56f030e651
commit 8caf29f186
5 changed files with 958 additions and 100 deletions
@@ -1,4 +1,5 @@
import SwiftUI
import VelodyDomain
struct iPhoneLibraryView: View {
@State private var viewModel = iPhoneLibraryViewModel()
@@ -33,7 +34,7 @@ struct iPhoneLibraryView: View {
}
}
Section("Remote tracks: \(viewModel.remoteTracks.count)") {
Section("Remote Library: \(viewModel.remoteTracks.count)") {
ForEach(viewModel.remoteTracks) { track in
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
@@ -50,7 +51,7 @@ struct iPhoneLibraryView: View {
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(statusColor(for: track.statusText), in: Capsule())
.background(statusColor(for: track.status), in: Capsule())
.foregroundStyle(.white)
}
Text("Duration: \(track.durationText)")
@@ -60,9 +61,13 @@ struct iPhoneLibraryView: View {
.font(.caption)
.foregroundStyle(.tertiary)
.textSelection(.enabled)
Text("Asset ID: \(track.assetID)")
.font(.caption)
.foregroundStyle(.tertiary)
.textSelection(.enabled)
HStack {
Button("Download") {
Button(track.downloadButtonTitle) {
Task {
await viewModel.downloadTrack(trackID: track.id)
}
@@ -79,7 +84,7 @@ struct iPhoneLibraryView: View {
}
if let error = track.lastDownloadError,
track.statusText == "Failed"
(track.status == .failed || track.status == .missing)
{
Text(error)
.font(.caption)
@@ -89,6 +94,47 @@ struct iPhoneLibraryView: View {
.padding(.vertical, 4)
}
}
Section("Available Offline: \(viewModel.availableOfflineTracks.count)") {
if viewModel.availableOfflineTracks.isEmpty {
Text("Downloaded tracks with a verified local MP3 will appear here.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.availableOfflineTracks) { track in
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(track.title)
.font(.headline)
Text(track.artist)
.foregroundStyle(.secondary)
}
Spacer()
Button(track.playButtonTitle) {
viewModel.togglePlayback(trackID: track.id)
}
.buttonStyle(.borderedProminent)
}
Text("Duration: \(track.durationText)")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Remote track ID: \(track.remoteTrackID)")
.font(.caption)
.foregroundStyle(.tertiary)
.textSelection(.enabled)
Text("Asset ID: \(track.assetID)")
.font(.caption)
.foregroundStyle(.tertiary)
.textSelection(.enabled)
}
.padding(.vertical, 4)
}
}
}
}
.overlay {
overlayView
@@ -158,16 +204,18 @@ struct iPhoneLibraryView: View {
}
}
private func statusColor(for status: String) -> Color {
private func statusColor(for status: OfflineLibraryRemoteTrackStatus) -> Color {
switch status {
case "Downloading":
return .orange
case "Downloaded":
return .green
case "Failed":
return .red
default:
case .notDownloaded:
return .gray
case .downloading:
return .blue
case .downloaded:
return .green
case .missing:
return .orange
case .failed:
return .red
}
}
}
@@ -175,6 +175,7 @@ final class iPhoneLibraryViewModel {
}
var remoteTracks: [RemoteTrackRowViewData] = []
var availableOfflineTracks: [AvailableOfflineTrackRowViewData] = []
var syncStatus = "Remote library not synced yet."
var state: ViewState = .idle
var nowPlaying = iPhoneNowPlayingState(
@@ -188,10 +189,12 @@ final class iPhoneLibraryViewModel {
private let environment: ServerEnvironment
private let apiClient: any VelodyAPIClient
private let syncService: RemoteLibrarySyncService
private let offlineLibraryService: OfflineLibraryService
private let keychainService: any KeychainService
private let player: any iPhoneLocalAudioPlaying
private var cachedRemoteTracks: [RemoteTrack] = []
private var downloadStatesByTrackID: [String: RemoteTrackDownloadState] = [:]
private var cachedRemoteTracksByID: [String: RemoteTrack] = [:]
private var cachedRemoteLibraryTracks: [OfflineLibraryRemoteTrack] = []
private var cachedAvailableOfflineTracks: [OfflineLibraryTrack] = []
private var hasLoaded = false
init(
@@ -208,17 +211,23 @@ final class iPhoneLibraryViewModel {
let store = Self.makeRemoteLibraryStore()
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore()
let repository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store
)
let syncService = RemoteLibrarySyncService(
repository: repository,
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
)
self.environment = environment
self.apiClient = apiClient
self.keychainService = keychainService
self.player = player ?? iPhoneLocalAudioPlayer()
self.syncService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store
),
downloadStateStore: downloadStateStore,
self.syncService = syncService
self.offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore
)
self.player.onStateChange = { [weak self] state in
@@ -231,10 +240,8 @@ final class iPhoneLibraryViewModel {
hasLoaded = true
do {
cachedRemoteTracks = try await syncService.loadCachedRemoteTracks()
downloadStatesByTrackID = try await loadDownloadStateDictionary()
rebuildRows()
applyRestoredTracks(cachedRemoteTracks)
let snapshot = try await reloadOfflineLibrary()
applyRestoredTracks(snapshot)
} catch {
state = .idle
syncStatus = "Failed to load cached remote library: \(error.localizedDescription)"
@@ -247,10 +254,9 @@ final class iPhoneLibraryViewModel {
do {
let deviceId = try await currentOrRegisterDeviceID()
cachedRemoteTracks = try await syncService.syncRemoteLibrary(deviceId: deviceId)
downloadStatesByTrackID = try await loadDownloadStateDictionary()
rebuildRows()
applySyncedTracks(cachedRemoteTracks)
_ = 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)"
@@ -258,75 +264,73 @@ final class iPhoneLibraryViewModel {
}
func downloadTrack(trackID: String) async {
guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else {
guard let track = cachedRemoteTracksByID[trackID] else {
return
}
let currentState = downloadStatesByTrackID[trackID]
if currentState?.downloadStatus == .downloaded {
return
updateRemoteTrack(trackID: trackID) { remoteTrack in
var updatedTrack = remoteTrack
updatedTrack.status = .downloading
updatedTrack.lastDownloadError = nil
return updatedTrack
}
downloadStatesByTrackID[trackID] = RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: currentState?.localFilePath ?? "",
downloadedAt: currentState?.downloadedAt,
downloadStatus: .downloading,
lastDownloadError: nil
)
rebuildRows()
syncStatus = "Downloading \(track.title)..."
do {
let deviceId = try await currentOrRegisterDeviceID()
let downloadState = try await syncService.downloadTrack(track, deviceId: deviceId)
downloadStatesByTrackID[track.trackId] = downloadState
rebuildRows()
_ = try await syncService.downloadTrack(track, deviceId: deviceId)
_ = try await reloadOfflineLibrary()
syncStatus = "Downloaded \(track.title)."
} catch {
downloadStatesByTrackID = (try? await loadDownloadStateDictionary()) ?? downloadStatesByTrackID
rebuildRows()
_ = try? await reloadOfflineLibrary()
syncStatus = "Download failed for \(track.title): \(error.localizedDescription)"
}
}
func togglePlayback(trackID: String) {
guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else {
guard let track = cachedRemoteTracksByID[trackID] else {
return
}
guard let downloadState = downloadStatesByTrackID[track.trackId],
downloadState.downloadStatus == .downloaded,
downloadState.hasLocalFile
else {
syncStatus = "Download the track before playing it offline."
if nowPlaying.trackID == track.trackId, nowPlaying.isPlaying {
player.pause()
return
}
let fileURL = URL(fileURLWithPath: downloadState.localFilePath)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
syncStatus = "The downloaded file for \(track.title) is missing."
return
}
do {
if nowPlaying.trackID == track.trackId {
if nowPlaying.isPlaying {
player.pause()
} else {
try player.resume()
}
} else {
try player.play(
trackID: track.trackId,
title: track.title,
artist: track.artist,
fileURL: fileURL
)
if nowPlaying.trackID == track.trackId, !nowPlaying.isPlaying {
do {
try player.resume()
} catch {
syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)"
refreshOfflineLibraryInBackground()
}
return
}
guard let offlineTrack = cachedAvailableOfflineTracks
.first(where: { $0.remoteTrackId == track.trackId })
else {
if cachedRemoteLibraryTracks.first(where: { $0.remoteTrack.trackId == track.trackId })?.status == .missing {
syncStatus = "The downloaded file for \(track.title) is missing."
} else {
syncStatus = "Download the track before playing it offline."
}
refreshOfflineLibraryInBackground()
return
}
let fileURL = URL(fileURLWithPath: offlineTrack.localFilePath)
do {
try player.play(
trackID: track.trackId,
title: track.title,
artist: track.artist,
fileURL: fileURL
)
} catch {
syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)"
refreshOfflineLibraryInBackground()
}
}
@@ -354,23 +358,23 @@ final class iPhoneLibraryViewModel {
return response.deviceId
}
private func applyRestoredTracks(_ tracks: [RemoteTrack]) {
if tracks.isEmpty {
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 \(tracks.count) cached remote track(s)."
syncStatus = "Restored \(snapshot.remoteTracks.count) cached remote track(s). Offline available: \(snapshot.availableTracks.count)."
}
}
private func applySyncedTracks(_ tracks: [RemoteTrack]) {
if tracks.isEmpty {
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: \(tracks.count)."
syncStatus = "Sync Remote Library completed. Remote tracks: \(snapshot.remoteTracks.count). Offline available: \(snapshot.availableTracks.count)."
}
}
@@ -398,19 +402,29 @@ final class iPhoneLibraryViewModel {
return InMemoryOfflineAudioFileStore()
}
private func loadDownloadStateDictionary() async throws -> [String: RemoteTrackDownloadState] {
Dictionary(
uniqueKeysWithValues: try await syncService
.loadDownloadStates()
.map { ($0.remoteTrackId, $0) }
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) }
)
rebuildRows()
return snapshot
}
private func rebuildRows() {
remoteTracks = cachedRemoteTracks.map { track in
remoteTracks = cachedRemoteLibraryTracks.map { track in
RemoteTrackRowViewData(
track: track,
downloadState: downloadStatesByTrackID[track.trackId],
nowPlaying: nowPlaying
)
}
availableOfflineTracks = cachedAvailableOfflineTracks.map { track in
AvailableOfflineTrackRowViewData(
track: track,
nowPlaying: nowPlaying
)
}
@@ -421,6 +435,29 @@ final class iPhoneLibraryViewModel {
rebuildRows()
}
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 refreshOfflineLibraryInBackground() {
Task { @MainActor [weak self] in
guard let self else {
return
}
_ = try? await self.reloadOfflineLibrary()
}
}
#if canImport(UIKit)
private static var currentDeviceName: String {
UIDevice.current.name
@@ -439,39 +476,43 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
let artist: String
let durationText: String
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?
init(
track: RemoteTrack,
downloadState: RemoteTrackDownloadState?,
track: OfflineLibraryRemoteTrack,
nowPlaying: iPhoneNowPlayingState
) {
id = track.trackId
title = track.title
artist = track.artist
durationText = Self.formatDuration(seconds: track.durationSeconds)
remoteTrackID = track.trackId
let status = downloadState?.downloadStatus ?? .notDownloaded
statusText = Self.statusText(for: status)
canDownload = status == .notDownloaded || status == .failed
canPlay = status == .downloaded
playButtonTitle = nowPlaying.trackID == track.trackId && nowPlaying.isPlaying
id = track.remoteTrack.trackId
title = track.remoteTrack.title
artist = track.remoteTrack.artist
durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds)
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 = downloadState?.lastDownloadError
lastDownloadError = track.lastDownloadError
}
private static func formatDuration(seconds: Int) -> String {
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: RemoteTrackDownloadStatus) -> String {
private static func statusText(for status: OfflineLibraryRemoteTrackStatus) -> String {
switch status {
case .notDownloaded:
return "Not downloaded"
@@ -479,8 +520,48 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
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 remoteTrackID: String
let assetID: String
let playButtonTitle: String
init(
track: OfflineLibraryTrack,
nowPlaying: iPhoneNowPlayingState
) {
id = track.remoteTrackId
title = track.title
artist = track.artist
durationText = RemoteTrackRowViewData.formatDuration(seconds: track.durationSeconds)
remoteTrackID = track.remoteTrackId
assetID = track.assetId
playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying
? "Pause"
: "Play"
}
}