Implement artwork download and cache

This commit is contained in:
diyaa
2026-05-30 09:43:14 +02:00
parent 8caf29f186
commit 7b1952794c
23 changed files with 1261 additions and 66 deletions
@@ -1,5 +1,8 @@
import SwiftUI
import VelodyDomain
#if canImport(UIKit)
import UIKit
#endif
struct iPhoneLibraryView: View {
@State private var viewModel = iPhoneLibraryViewModel()
@@ -37,22 +40,28 @@ struct iPhoneLibraryView: View {
Section("Remote Library: \(viewModel.remoteTracks.count)") {
ForEach(viewModel.remoteTracks) { track in
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
HStack(alignment: .top, spacing: 12) {
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
VStack(alignment: .leading, spacing: 4) {
Text(track.title)
.font(.headline)
Text(track.artist)
.foregroundStyle(.secondary)
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(track.title)
.font(.headline)
Text(track.artist)
.foregroundStyle(.secondary)
}
Spacer()
Text(track.statusText)
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(statusColor(for: track.status), in: Capsule())
.foregroundStyle(.white)
}
}
Spacer()
Text(track.statusText)
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(statusColor(for: track.status), in: Capsule())
.foregroundStyle(.white)
}
Text("Duration: \(track.durationText)")
.font(.subheadline)
@@ -103,20 +112,26 @@ struct iPhoneLibraryView: View {
} else {
ForEach(viewModel.availableOfflineTracks) { track in
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
HStack(alignment: .top, spacing: 12) {
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
VStack(alignment: .leading, spacing: 4) {
Text(track.title)
.font(.headline)
Text(track.artist)
.foregroundStyle(.secondary)
}
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(track.title)
.font(.headline)
Text(track.artist)
.foregroundStyle(.secondary)
}
Spacer()
Spacer()
Button(track.playButtonTitle) {
viewModel.togglePlayback(trackID: track.id)
Button(track.playButtonTitle) {
viewModel.togglePlayback(trackID: track.id)
}
.buttonStyle(.borderedProminent)
}
}
.buttonStyle(.borderedProminent)
}
Text("Duration: \(track.durationText)")
@@ -219,3 +234,39 @@ struct iPhoneLibraryView: View {
}
}
}
private struct ArtworkThumbnailView: View {
let localFilePath: String?
var body: some View {
Group {
if let artworkImage {
Image(uiImage: artworkImage)
.resizable()
.scaledToFill()
} else {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.gray.opacity(0.14))
Image(systemName: "music.note")
.font(.headline)
.foregroundStyle(.secondary)
}
}
}
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.12), lineWidth: 1)
)
}
private var artworkImage: UIImage? {
guard let localFilePath, !localFilePath.isEmpty else {
return nil
}
return UIImage(contentsOfFile: localFilePath)
}
}
@@ -211,6 +211,7 @@ final class iPhoneLibraryViewModel {
let store = Self.makeRemoteLibraryStore()
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore()
let artworkStore = Self.makeArtworkStore()
let repository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store
@@ -218,7 +219,8 @@ final class iPhoneLibraryViewModel {
let syncService = RemoteLibrarySyncService(
repository: repository,
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
self.environment = environment
@@ -228,7 +230,8 @@ final class iPhoneLibraryViewModel {
self.syncService = syncService
self.offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
self.player.onStateChange = { [weak self] state in
self?.handleNowPlayingStateChange(state)
@@ -402,6 +405,14 @@ final class iPhoneLibraryViewModel {
return InMemoryOfflineAudioFileStore()
}
private static func makeArtworkStore() -> any ArtworkStore {
if let store = try? FileArtworkStore() {
return store
}
return InMemoryArtworkStore()
}
private func reloadOfflineLibrary() async throws -> OfflineLibrarySnapshot {
let snapshot = try await offlineLibraryService.loadSnapshot()
@@ -484,6 +495,7 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
let canPlay: Bool
let playButtonTitle: String
let lastDownloadError: String?
let artworkLocalFilePath: String?
init(
track: OfflineLibraryRemoteTrack,
@@ -504,6 +516,7 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
? "Pause"
: "Play"
lastDownloadError = track.lastDownloadError
artworkLocalFilePath = track.localArtworkFilePath
}
static func formatDuration(seconds: Int) -> String {
@@ -549,6 +562,7 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
let remoteTrackID: String
let assetID: String
let playButtonTitle: String
let artworkLocalFilePath: String?
init(
track: OfflineLibraryTrack,
@@ -563,5 +577,6 @@ struct AvailableOfflineTrackRowViewData: Identifiable, Equatable {
playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying
? "Pause"
: "Play"
artworkLocalFilePath = track.localArtworkFilePath
}
}