Implement Milestone 7.2 offline audio downloads

This commit is contained in:
diyaa
2026-05-30 07:11:20 +02:00
parent 6e73c1878e
commit 56f030e651
19 changed files with 1822 additions and 31 deletions
@@ -6,13 +6,53 @@ struct iPhoneLibraryView: View {
var body: some View {
NavigationStack {
List {
if let currentTitle = viewModel.nowPlaying.title {
Section("Now Playing") {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text(currentTitle)
.font(.headline)
if let artist = viewModel.nowPlaying.artist {
Text(artist)
.foregroundStyle(.secondary)
}
Text(viewModel.nowPlaying.isPlaying ? "Playing offline" : "Paused")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let trackID = viewModel.nowPlaying.trackID {
Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") {
viewModel.togglePlayback(trackID: trackID)
}
.buttonStyle(.borderedProminent)
}
}
}
}
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)
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.statusText), in: Capsule())
.foregroundStyle(.white)
}
Text("Duration: \(track.durationText)")
.font(.subheadline)
.foregroundStyle(.secondary)
@@ -20,6 +60,31 @@ struct iPhoneLibraryView: View {
.font(.caption)
.foregroundStyle(.tertiary)
.textSelection(.enabled)
HStack {
Button("Download") {
Task {
await viewModel.downloadTrack(trackID: track.id)
}
}
.buttonStyle(.bordered)
.disabled(!track.canDownload)
if track.canPlay {
Button(track.playButtonTitle) {
viewModel.togglePlayback(trackID: track.id)
}
.buttonStyle(.borderedProminent)
}
}
if let error = track.lastDownloadError,
track.statusText == "Failed"
{
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
}
.padding(.vertical, 4)
}
@@ -40,12 +105,20 @@ struct iPhoneLibraryView: View {
}
}
.safeAreaInset(edge: .bottom) {
Text(viewModel.syncStatus)
.font(.footnote)
.foregroundStyle(.secondary)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.ultraThinMaterial)
VStack(alignment: .leading, spacing: 4) {
if let playbackError = viewModel.nowPlaying.errorMessage {
Text(playbackError)
.font(.footnote)
.foregroundStyle(.red)
}
Text(viewModel.syncStatus)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.ultraThinMaterial)
}
}
.task {
@@ -84,4 +157,17 @@ struct iPhoneLibraryView: View {
}
}
}
private func statusColor(for status: String) -> Color {
switch status {
case "Downloading":
return .orange
case "Downloaded":
return .green
case "Failed":
return .red
default:
return .gray
}
}
}
@@ -6,9 +6,163 @@ import VelodyPersistence
import VelodySync
import VelodyUtilities
#if canImport(UIKit)
import AVFoundation
import UIKit
#endif
@MainActor
protocol iPhoneLocalAudioPlaying: AnyObject {
var onStateChange: ((iPhoneNowPlayingState) -> Void)? { get set }
var state: iPhoneNowPlayingState { get }
func play(
trackID: String,
title: String,
artist: String,
fileURL: URL
) throws
func resume() throws
func pause()
}
struct iPhoneNowPlayingState: Equatable {
var trackID: String?
var title: String?
var artist: String?
var isPlaying: Bool
var errorMessage: String?
}
@MainActor
final class iPhoneLocalAudioPlayer: NSObject, iPhoneLocalAudioPlaying, AVAudioPlayerDelegate {
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
private(set) var state = iPhoneNowPlayingState(
trackID: nil,
title: nil,
artist: nil,
isPlaying: false,
errorMessage: nil
) {
didSet {
onStateChange?(state)
}
}
private var audioPlayer: AVAudioPlayer?
func play(
trackID: String,
title: String,
artist: String,
fileURL: URL
) throws {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
state = iPhoneNowPlayingState(
trackID: trackID,
title: title,
artist: artist,
isPlaying: false,
errorMessage: "The downloaded file could not be found."
)
throw NSError(
domain: "VelodyiPhonePlayback",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "The downloaded file could not be found."]
)
}
do {
try configureAudioSession()
audioPlayer?.stop()
let audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
audioPlayer.delegate = self
audioPlayer.prepareToPlay()
self.audioPlayer = audioPlayer
guard audioPlayer.play() else {
state = iPhoneNowPlayingState(
trackID: trackID,
title: title,
artist: artist,
isPlaying: false,
errorMessage: "Playback could not be started."
)
throw NSError(
domain: "VelodyiPhonePlayback",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Playback could not be started."]
)
}
state = iPhoneNowPlayingState(
trackID: trackID,
title: title,
artist: artist,
isPlaying: true,
errorMessage: nil
)
} catch {
if state.trackID == nil {
state = iPhoneNowPlayingState(
trackID: trackID,
title: title,
artist: artist,
isPlaying: false,
errorMessage: "The downloaded audio file could not be opened."
)
}
throw error
}
}
func resume() throws {
guard let audioPlayer else {
state.errorMessage = "No downloaded track is loaded."
throw NSError(
domain: "VelodyiPhonePlayback",
code: 3,
userInfo: [NSLocalizedDescriptionKey: "No downloaded track is loaded."]
)
}
guard audioPlayer.play() else {
state.errorMessage = "Playback could not be resumed."
throw NSError(
domain: "VelodyiPhonePlayback",
code: 4,
userInfo: [NSLocalizedDescriptionKey: "Playback could not be resumed."]
)
}
state.isPlaying = true
state.errorMessage = nil
}
func pause() {
audioPlayer?.pause()
state.isPlaying = false
}
nonisolated func audioPlayerDidFinishPlaying(
_ player: AVAudioPlayer,
successfully flag: Bool
) {
guard flag else {
return
}
Task { @MainActor [weak self] in
self?.state.isPlaying = false
}
}
private func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default)
try session.setActive(true)
}
}
@MainActor
@Observable
final class iPhoneLibraryViewModel {
@@ -23,14 +177,25 @@ final class iPhoneLibraryViewModel {
var remoteTracks: [RemoteTrackRowViewData] = []
var syncStatus = "Remote library not synced yet."
var state: ViewState = .idle
var nowPlaying = iPhoneNowPlayingState(
trackID: nil,
title: nil,
artist: nil,
isPlaying: false,
errorMessage: nil
)
private let environment: ServerEnvironment
private let apiClient: any VelodyAPIClient
private let syncService: RemoteLibrarySyncService
private let keychainService: any KeychainService
private let player: any iPhoneLocalAudioPlaying
private var cachedRemoteTracks: [RemoteTrack] = []
private var downloadStatesByTrackID: [String: RemoteTrackDownloadState] = [:]
private var hasLoaded = false
init(
player: (any iPhoneLocalAudioPlaying)? = nil,
keychainService: any KeychainService = SystemKeychainService(
service: "de.diyaa.velody.iphone"
)
@@ -41,16 +206,24 @@ final class iPhoneLibraryViewModel {
)
let apiClient = URLSessionVelodyAPIClient(environment: environment)
let store = Self.makeRemoteLibraryStore()
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore()
self.environment = environment
self.apiClient = apiClient
self.keychainService = keychainService
self.player = player ?? iPhoneLocalAudioPlayer()
self.syncService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store
)
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
)
self.player.onStateChange = { [weak self] state in
self?.handleNowPlayingStateChange(state)
}
}
func loadIfNeeded() async {
@@ -58,8 +231,10 @@ final class iPhoneLibraryViewModel {
hasLoaded = true
do {
let persistedTracks = try await syncService.loadCachedRemoteTracks()
applyRestoredTracks(persistedTracks)
cachedRemoteTracks = try await syncService.loadCachedRemoteTracks()
downloadStatesByTrackID = try await loadDownloadStateDictionary()
rebuildRows()
applyRestoredTracks(cachedRemoteTracks)
} catch {
state = .idle
syncStatus = "Failed to load cached remote library: \(error.localizedDescription)"
@@ -72,14 +247,89 @@ final class iPhoneLibraryViewModel {
do {
let deviceId = try await currentOrRegisterDeviceID()
let tracks = try await syncService.syncRemoteLibrary(deviceId: deviceId)
applySyncedTracks(tracks)
cachedRemoteTracks = try await syncService.syncRemoteLibrary(deviceId: deviceId)
downloadStatesByTrackID = try await loadDownloadStateDictionary()
rebuildRows()
applySyncedTracks(cachedRemoteTracks)
} catch {
state = .networkError("Remote library sync failed.")
syncStatus = "Remote library sync failed: \(error.localizedDescription)"
}
}
func downloadTrack(trackID: String) async {
guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else {
return
}
let currentState = downloadStatesByTrackID[trackID]
if currentState?.downloadStatus == .downloaded {
return
}
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()
syncStatus = "Downloaded \(track.title)."
} catch {
downloadStatesByTrackID = (try? await loadDownloadStateDictionary()) ?? downloadStatesByTrackID
rebuildRows()
syncStatus = "Download failed for \(track.title): \(error.localizedDescription)"
}
}
func togglePlayback(trackID: String) {
guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else {
return
}
guard let downloadState = downloadStatesByTrackID[track.trackId],
downloadState.downloadStatus == .downloaded,
downloadState.hasLocalFile
else {
syncStatus = "Download the track before playing it offline."
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
)
}
} catch {
syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)"
}
}
private func currentOrRegisterDeviceID() async throws -> String {
if let existingDeviceID = try await keychainService.loadValue(
forKey: Self.deviceIDKey
@@ -105,8 +355,6 @@ final class iPhoneLibraryViewModel {
}
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."
@@ -117,8 +365,6 @@ final class iPhoneLibraryViewModel {
}
private func applySyncedTracks(_ tracks: [RemoteTrack]) {
remoteTracks = tracks.map(RemoteTrackRowViewData.init(track:))
if tracks.isEmpty {
state = .empty
syncStatus = "Remote library is empty."
@@ -136,6 +382,45 @@ final class iPhoneLibraryViewModel {
return InMemoryRemoteLibraryStore()
}
private static func makeRemoteTrackDownloadStateStore() -> any RemoteTrackDownloadStateStore {
if let store = try? FileRemoteTrackDownloadStateStore() {
return store
}
return InMemoryRemoteTrackDownloadStateStore()
}
private static func makeOfflineAudioFileStore() -> any OfflineAudioFileStore {
if let store = try? FileOfflineAudioFileStore() {
return store
}
return InMemoryOfflineAudioFileStore()
}
private func loadDownloadStateDictionary() async throws -> [String: RemoteTrackDownloadState] {
Dictionary(
uniqueKeysWithValues: try await syncService
.loadDownloadStates()
.map { ($0.remoteTrackId, $0) }
)
}
private func rebuildRows() {
remoteTracks = cachedRemoteTracks.map { track in
RemoteTrackRowViewData(
track: track,
downloadState: downloadStatesByTrackID[track.trackId],
nowPlaying: nowPlaying
)
}
}
private func handleNowPlayingStateChange(_ state: iPhoneNowPlayingState) {
nowPlaying = state
rebuildRows()
}
#if canImport(UIKit)
private static var currentDeviceName: String {
UIDevice.current.name
@@ -154,13 +439,30 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
let artist: String
let durationText: String
let remoteTrackID: String
let statusText: String
let canDownload: Bool
let canPlay: Bool
let playButtonTitle: String
let lastDownloadError: String?
init(track: RemoteTrack) {
init(
track: RemoteTrack,
downloadState: RemoteTrackDownloadState?,
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
? "Pause"
: "Play"
lastDownloadError = downloadState?.lastDownloadError
}
private static func formatDuration(seconds: Int) -> String {
@@ -168,4 +470,17 @@ struct RemoteTrackRowViewData: Identifiable, Equatable {
let remainingSeconds = seconds % 60
return "\(minutes):\(String(format: "%02d", remainingSeconds))"
}
private static func statusText(for status: RemoteTrackDownloadStatus) -> String {
switch status {
case .notDownloaded:
return "Not downloaded"
case .downloading:
return "Downloading"
case .downloaded:
return "Downloaded"
case .failed:
return "Failed"
}
}
}