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 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" ) ) { 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() 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 { guard !hasLoaded else { return } hasLoaded = true do { cachedRemoteTracks = try await syncService.loadCachedRemoteTracks() downloadStatesByTrackID = try await loadDownloadStateDictionary() rebuildRows() applyRestoredTracks(cachedRemoteTracks) } 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() 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 ), !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(_ tracks: [RemoteTrack]) { if tracks.isEmpty { state = .idle syncStatus = "Tap Sync Remote Library to load remote metadata." } else { state = .success syncStatus = "Restored \(tracks.count) cached remote track(s)." } } private func applySyncedTracks(_ tracks: [RemoteTrack]) { if tracks.isEmpty { state = .empty syncStatus = "Remote library is empty." } else { state = .success syncStatus = "Sync Remote Library completed. Remote tracks: \(tracks.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 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 } #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 statusText: String let canDownload: Bool let canPlay: Bool let playButtonTitle: String let lastDownloadError: String? 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 { let minutes = seconds / 60 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" } } }