velody/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift
2026-05-31 08:57:54 +02:00

605 lines
19 KiB
Swift

import Foundation
import Observation
import VelodyDomain
import VelodyNetworking
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 {
enum ViewState: Equatable {
case idle
case loading
case success
case empty
case networkError(String)
}
var remoteTracks: [RemoteTrackRowViewData] = []
var availableOfflineTracks: [AvailableOfflineTrackRowViewData] = []
var searchText = "" {
didSet {
guard searchText != oldValue else {
return
}
rebuildRows()
}
}
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 offlineLibraryService: OfflineLibraryService
private let keychainService: any KeychainService
private let player: any iPhoneLocalAudioPlaying
private var cachedRemoteTracksByID: [String: RemoteTrack] = [:]
private var cachedRemoteLibraryTracks: [OfflineLibraryRemoteTrack] = []
private var cachedAvailableOfflineTracks: [OfflineLibraryTrack] = []
private var hasLoaded = false
var hasActiveSearch: Bool {
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var hasCachedRemoteTracks: Bool {
!cachedRemoteLibraryTracks.isEmpty
}
init(
player: (any iPhoneLocalAudioPlaying)? = nil,
keychainService: any KeychainService = SystemKeychainService(
service: "de.diyaa.velody.iphone"
)
) {
let environment = ServerEnvironment(
baseURL: ServerEnvironment.defaultLocalBaseURL,
appVersion: "0.1.0"
)
let apiClient = URLSessionVelodyAPIClient(environment: environment)
let store = Self.makeRemoteLibraryStore()
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore()
let artworkStore = Self.makeArtworkStore()
let repository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store
)
let syncService = RemoteLibrarySyncService(
repository: repository,
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
self.environment = environment
self.apiClient = apiClient
self.keychainService = keychainService
self.player = player ?? iPhoneLocalAudioPlayer()
self.syncService = syncService
self.offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore,
artworkStore: artworkStore
)
self.player.onStateChange = { [weak self] state in
self?.handleNowPlayingStateChange(state)
}
}
func loadIfNeeded() async {
guard !hasLoaded else { return }
hasLoaded = true
do {
let snapshot = try await reloadOfflineLibrary()
applyRestoredTracks(snapshot)
} catch {
state = .idle
syncStatus = "Failed to load cached remote library: \(error.localizedDescription)"
}
}
func refreshSync() async {
state = .loading
syncStatus = "Syncing remote library..."
do {
let deviceId = try await currentOrRegisterDeviceID()
_ = 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)"
}
}
func downloadTrack(trackID: String) async {
guard let track = cachedRemoteTracksByID[trackID] else {
return
}
updateRemoteTrack(trackID: trackID) { remoteTrack in
var updatedTrack = remoteTrack
updatedTrack.status = .downloading
updatedTrack.lastDownloadError = nil
return updatedTrack
}
rebuildRows()
syncStatus = "Downloading \(track.title)..."
do {
let deviceId = try await currentOrRegisterDeviceID()
_ = try await syncService.downloadTrack(track, deviceId: deviceId)
_ = try await reloadOfflineLibrary()
syncStatus = "Downloaded \(track.title)."
} catch {
_ = try? await reloadOfflineLibrary()
syncStatus = "Download failed for \(track.title): \(error.localizedDescription)"
}
}
func togglePlayback(trackID: String) {
guard let track = cachedRemoteTracksByID[trackID] else {
return
}
if nowPlaying.trackID == track.trackId, nowPlaying.isPlaying {
player.pause()
return
}
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()
}
}
private func currentOrRegisterDeviceID() async throws -> String {
if let existingDeviceID = try await keychainService.loadValue(
forKey: Self.deviceIDKey
), !existingDeviceID.isEmpty {
return existingDeviceID
}
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(_ snapshot: OfflineLibrarySnapshot) {
if snapshot.remoteTracks.isEmpty {
state = .idle
syncStatus = "Tap Sync Remote Library to load remote metadata."
} else {
state = .success
syncStatus = "Restored \(snapshot.remoteTracks.count) cached remote track(s). Offline available: \(snapshot.availableTracks.count)."
}
}
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: \(snapshot.remoteTracks.count). Offline available: \(snapshot.availableTracks.count)."
}
}
private static func makeRemoteLibraryStore() -> any RemoteLibraryStore {
if let store = try? FileRemoteLibraryStore() {
return store
}
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 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()
cachedRemoteLibraryTracks = snapshot.remoteTracks
cachedAvailableOfflineTracks = snapshot.availableTracks
cachedRemoteTracksByID = Dictionary(
uniqueKeysWithValues: snapshot.remoteTracks.map { ($0.remoteTrack.trackId, $0.remoteTrack) }
)
rebuildRows()
return snapshot
}
private func rebuildRows() {
let filteredSnapshot = OfflineLibrarySnapshot(
remoteTracks: cachedRemoteLibraryTracks,
availableTracks: cachedAvailableOfflineTracks
).filtered(searchText: searchText)
remoteTracks = filteredSnapshot.remoteTracks.map { track in
RemoteTrackRowViewData(
track: track,
nowPlaying: nowPlaying
)
}
availableOfflineTracks = filteredSnapshot.availableTracks.map { track in
AvailableOfflineTrackRowViewData(
track: track,
nowPlaying: nowPlaying
)
}
}
private func handleNowPlayingStateChange(_ state: iPhoneNowPlayingState) {
nowPlaying = state
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
}
#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
let assetID: String
let status: OfflineLibraryRemoteTrackStatus
let statusText: String
let canDownload: Bool
let downloadButtonTitle: String
let canPlay: Bool
let playButtonTitle: String
let lastDownloadError: String?
let artworkLocalFilePath: String?
init(
track: OfflineLibraryRemoteTrack,
nowPlaying: iPhoneNowPlayingState
) {
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 = track.lastDownloadError
artworkLocalFilePath = track.localArtworkFilePath
}
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: OfflineLibraryRemoteTrackStatus) -> String {
switch status {
case .notDownloaded:
return "Not downloaded"
case .downloading:
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
let artworkLocalFilePath: 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"
artworkLocalFilePath = track.localArtworkFilePath
}
}