Implement Milestone 7.2 offline audio downloads
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user