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 } }