import Foundation import Observation import VelodyDomain import VelodyNetworking import VelodyPlayback import VelodyPersistence import VelodySync import VelodyUtilities #if canImport(UIKit) import UIKit #endif @MainActor protocol iPhoneLocalAudioPlaying: AnyObject { var onStateChange: ((iPhoneNowPlayingState) -> Void)? { get set } var state: iPhoneNowPlayingState { get } func setCatalogTracks(_ tracks: [LibraryTrack]) func play(trackID: String) func pause() func stop() func seek(to time: Double) } enum iPhonePlaybackState: Equatable { case playing case paused case stopped case missingFile case failed var displayText: String { switch self { case .playing: return "Playing" case .paused: return "Paused" case .stopped: return "Stopped" case .missingFile: return "Missing file" case .failed: return "Failed" } } } struct iPhoneNowPlayingState: Equatable { var trackID: String? var title: String? var artist: String? var playbackState: iPhonePlaybackState var currentTime: Double var duration: Double var errorMessage: String? static let empty = iPhoneNowPlayingState( trackID: nil, title: nil, artist: nil, playbackState: .stopped, currentTime: 0, duration: 0, errorMessage: nil ) var isPlaying: Bool { playbackState == .playing } var hasTrack: Bool { trackID != nil } var canSeek: Bool { hasTrack && duration > 0 && playbackState != .missingFile && playbackState != .failed } } private struct StorediPhonePlaybackSession: Codable { var currentTrackID: String? var currentTime: Double } struct iPhonePlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable { private let userDefaults: UserDefaults private let storageKey: String private let encoder = JSONEncoder() private let decoder = JSONDecoder() init( userDefaults: UserDefaults = .standard, storageKey: String = "velody.iphone.playback.session" ) { self.userDefaults = userDefaults self.storageKey = storageKey } func loadSession() -> PlaybackSessionSnapshot? { guard let data = userDefaults.data(forKey: storageKey), let storedSession = try? decoder.decode(StorediPhonePlaybackSession.self, from: data) else { return nil } return PlaybackSessionSnapshot( queueTrackIDs: [], currentTrackID: storedSession.currentTrackID, currentTime: storedSession.currentTime, isShuffleEnabled: false, repeatMode: .off ) } func saveSession(_ session: PlaybackSessionSnapshot) { guard let currentTrackID = session.currentTrackID, !currentTrackID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { clearSession() return } let storedSession = StorediPhonePlaybackSession( currentTrackID: currentTrackID, currentTime: session.currentTime ) guard let data = try? encoder.encode(storedSession) else { return } userDefaults.set(data, forKey: storageKey) } func clearSession() { userDefaults.removeObject(forKey: storageKey) } } @MainActor final class iPhonePlaybackControllerPlayer: iPhoneLocalAudioPlaying { var onStateChange: ((iPhoneNowPlayingState) -> Void)? private(set) var state: iPhoneNowPlayingState = .empty { didSet { onStateChange?(state) } } private let controller: PlaybackController private var idleStateHint: iPhonePlaybackState = .stopped init( controller: PlaybackController? = nil ) { self.controller = controller ?? PlaybackController( sessionStore: iPhonePlaybackSessionStore() ) self.state = Self.makeState( from: self.controller.nowPlayingState, idleStateHint: .stopped ) self.controller.onStateChange = { [weak self] state in self?.apply(state) } } func setCatalogTracks(_ tracks: [LibraryTrack]) { controller.setCatalogTracks(tracks) apply(controller.nowPlayingState) } func play(trackID: String) { idleStateHint = .paused controller.play(trackID: trackID) apply(controller.nowPlayingState) } func pause() { idleStateHint = .paused controller.pause() apply(controller.nowPlayingState) } func stop() { idleStateHint = .stopped controller.stop() apply(controller.nowPlayingState) } func seek(to time: Double) { controller.seek(to: time) apply(controller.nowPlayingState) } private func apply(_ nowPlayingState: NowPlayingState) { let effectiveDuration = max( nowPlayingState.duration, nowPlayingState.currentTrack?.durationSeconds ?? 0 ) if nowPlayingState.isPlaying { idleStateHint = .paused } else if nowPlayingState.currentTrack == nil { idleStateHint = .stopped } else if effectiveDuration > 0, nowPlayingState.currentTime >= max(effectiveDuration - 0.25, 0) { idleStateHint = .stopped } state = Self.makeState( from: nowPlayingState, idleStateHint: idleStateHint ) } private static func makeState( from nowPlayingState: NowPlayingState, idleStateHint: iPhonePlaybackState ) -> iPhoneNowPlayingState { let effectiveDuration = max( nowPlayingState.duration, nowPlayingState.currentTrack?.durationSeconds ?? 0 ) let playbackState: iPhonePlaybackState if let playbackError = nowPlayingState.error { switch playbackError { case .missingLocalFile: playbackState = .missingFile default: playbackState = .failed } } else if nowPlayingState.isPlaying { playbackState = .playing } else if nowPlayingState.currentTrack != nil { if nowPlayingState.currentTime > 0, effectiveDuration > 0, nowPlayingState.currentTime < effectiveDuration { playbackState = .paused } else { playbackState = idleStateHint } } else { playbackState = .stopped } return iPhoneNowPlayingState( trackID: nowPlayingState.currentTrackID, title: nowPlayingState.currentTrack?.title, artist: nowPlayingState.currentTrack?.artist, playbackState: playbackState, currentTime: nowPlayingState.currentTime, duration: effectiveDuration, errorMessage: nowPlayingState.error?.localizedDescription ) } } enum NowPlayingDownloadBadge: Equatable { case downloaded case missing case offline var title: String { switch self { case .downloaded: return "Downloaded" case .missing: return "Missing" case .offline: return "Offline" } } } struct NowPlayingCardViewData: Equatable { let trackID: String let title: String let artist: String let artworkLocalFilePath: String? let playbackStateText: String let downloadBadge: NowPlayingDownloadBadge let currentTime: Double let duration: Double let currentTimeText: String let durationText: String let progress: Double let isPlaying: Bool let canSeek: Bool let errorMessage: String? } struct LibrarySectionMessage: Equatable { let title: String let body: String let systemImage: String } @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 = "Sync your remote library to see your tracks here." var state: ViewState = .idle var nowPlaying: iPhoneNowPlayingState = .empty var nowPlayingFavoriteTrackID: String? var isNowPlayingTrackFavorite = false private let environment: ServerEnvironment private let apiClient: any VelodyAPIClient private let syncService: RemoteLibrarySyncService private let offlineLibraryService: OfflineLibraryService private let favoriteTrackStore: any FavoriteTrackStore 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 cachedFavoriteTrackRecordsByID: [String: FavoriteTrackRecord] = [:] private var hasLoaded = false var hasActiveSearch: Bool { !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var hasCachedRemoteTracks: Bool { !cachedRemoteLibraryTracks.isEmpty } var isSyncing: Bool { state == .loading } var syncButtonTitle: String { isSyncing ? "Syncing..." : "Sync Remote Library" } var remoteSectionTitle: String { let count = remoteTracks.count return hasActiveSearch ? "Remote Library Results (\(count))" : "Remote Library (\(count))" } var availableOfflineSectionTitle: String { let count = availableOfflineTracks.count return hasActiveSearch ? "Offline Results (\(count))" : "Available Offline (\(count))" } var inlineSyncErrorMessage: LibrarySectionMessage? { guard hasCachedRemoteTracks, case .networkError = state else { return nil } return Self.connectionFailedMessage } var remoteEmptyStateMessage: LibrarySectionMessage? { guard remoteTracks.isEmpty else { return nil } if hasActiveSearch { return Self.searchEmptyMessage } switch state { case .loading: return nil case .networkError: return Self.connectionFailedMessage case .idle, .success, .empty: return Self.remoteLibraryEmptyMessage } } var availableOfflineEmptyStateMessage: LibrarySectionMessage? { guard availableOfflineTracks.isEmpty else { return nil } return hasActiveSearch ? Self.searchEmptyMessage : Self.availableOfflineEmptyMessage } var nowPlayingCard: NowPlayingCardViewData? { guard let trackID = nowPlaying.trackID, let title = nowPlaying.title else { return nil } let remoteTrack = cachedRemoteLibraryTracks.first(where: { $0.remoteTrack.trackId == trackID }) let fallbackOfflineTrack = cachedAvailableOfflineTracks.first(where: { $0.remoteTrackId == trackID }) let artist = nowPlaying.artist ?? remoteTrack?.remoteTrack.artist ?? fallbackOfflineTrack?.artist ?? "Unknown Artist" let duration = max( nowPlaying.duration, Double(remoteTrack?.remoteTrack.durationSeconds ?? fallbackOfflineTrack?.durationSeconds ?? 0) ) let clampedCurrentTime: Double if duration > 0 { clampedCurrentTime = min(max(nowPlaying.currentTime, 0), duration) } else { clampedCurrentTime = max(nowPlaying.currentTime, 0) } let progress: Double if duration > 0 { progress = min(max(clampedCurrentTime / duration, 0), 1) } else { progress = 0 } return NowPlayingCardViewData( trackID: trackID, title: title, artist: artist, artworkLocalFilePath: remoteTrack?.localArtworkFilePath ?? fallbackOfflineTrack?.localArtworkFilePath, playbackStateText: nowPlaying.playbackState.displayText, downloadBadge: nowPlayingDownloadBadge(for: trackID), currentTime: clampedCurrentTime, duration: duration, currentTimeText: Self.formatPlaybackTime(clampedCurrentTime), durationText: Self.formatPlaybackTime(duration), progress: progress, isPlaying: nowPlaying.isPlaying, canSeek: nowPlaying.canSeek && duration > 0, errorMessage: Self.userFacingPlaybackErrorMessage(for: nowPlaying) ) } convenience 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, deviceAccessTokenProvider: { try await keychainService.loadValue(forKey: Self.deviceAccessTokenKey) } ) let store = Self.makeRemoteLibraryStore() let syncCursorStore = Self.makeRemoteLibrarySyncCursorStore() let downloadStateStore = Self.makeRemoteTrackDownloadStateStore() let audioFileStore = Self.makeOfflineAudioFileStore() let artworkStore = Self.makeArtworkStore() let favoriteTrackStore = Self.makeFavoriteTrackStore() let repository = DefaultRemoteLibraryRepository( apiClient: apiClient, store: store, syncCursorStore: syncCursorStore ) let syncService = RemoteLibrarySyncService( repository: repository, downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, artworkStore: artworkStore ) let offlineLibraryService = OfflineLibraryService( syncService: syncService, audioFileStore: audioFileStore, artworkStore: artworkStore ) self.init( environment: environment, apiClient: apiClient, syncService: syncService, offlineLibraryService: offlineLibraryService, favoriteTrackStore: favoriteTrackStore, player: player ?? iPhonePlaybackControllerPlayer(), keychainService: keychainService ) } init( environment: ServerEnvironment, apiClient: any VelodyAPIClient, syncService: RemoteLibrarySyncService, offlineLibraryService: OfflineLibraryService, favoriteTrackStore: any FavoriteTrackStore, player: any iPhoneLocalAudioPlaying, keychainService: any KeychainService ) { self.environment = environment self.apiClient = apiClient self.syncService = syncService self.offlineLibraryService = offlineLibraryService self.favoriteTrackStore = favoriteTrackStore self.keychainService = keychainService self.player = player self.nowPlaying = player.state self.player.onStateChange = { [weak self] state in self?.handleNowPlayingStateChange(state) } } func loadIfNeeded() async { guard !hasLoaded else { return } hasLoaded = true var favoriteRestoreError: Error? do { try await reloadFavoriteTracks() } catch { favoriteRestoreError = error } do { let snapshot = try await reloadOfflineLibrary() applyRestoredTracks(snapshot) if let favoriteRestoreError { Self.logError(favoriteRestoreError, context: "Favorites restore failed") syncStatus += " Favorites may be out of date." } } catch { Self.logError(error, context: "Cached library restore failed") state = .idle syncStatus = "Could not restore the cached library." } } func refreshSync() async { guard !isSyncing else { return } state = .loading syncStatus = "Syncing your remote library..." do { let deviceId = try await currentOrRegisterDeviceID() _ = try await syncService.syncRemoteLibrary(deviceId: deviceId) let snapshot = try await reloadOfflineLibrary() applySyncedTracks(snapshot) } catch { Self.logError(error, context: "Remote library sync failed") state = .networkError(error.localizedDescription) syncStatus = Self.connectionFailedMessage.body } } 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 { Self.logError(error, context: "Download failed for \(track.title)") _ = try? await reloadOfflineLibrary() syncStatus = "Could not download \(track.title). Try again." } } func togglePlayback(trackID: String) { guard let track = cachedRemoteTracksByID[trackID] else { return } if nowPlaying.trackID == track.trackId, nowPlaying.isPlaying { player.pause() return } guard canAttemptPlayback(for: track.trackId) else { if remoteTrackStatus(for: track.trackId) == .missing { syncStatus = "The downloaded file for \(track.title) is missing. Re-download it to play again." } else { syncStatus = "Download the track before playing it offline." } return } player.play(trackID: track.trackId) handlePlaybackResult(for: track) } func stopPlayback() { player.stop() } func seekPlayback(to time: Double) { player.seek(to: time) } func toggleFavorite(trackID: String) async { guard hasTrackInLibrarySnapshot(trackID) else { return } let previousFavorites = cachedFavoriteTrackRecordsByID if cachedFavoriteTrackRecordsByID[trackID] != nil { cachedFavoriteTrackRecordsByID.removeValue(forKey: trackID) } else { cachedFavoriteTrackRecordsByID[trackID] = FavoriteTrackRecord( remoteTrackId: trackID, favoritedAt: Date() ) } rebuildRows() do { try await favoriteTrackStore.saveFavoriteTracks(Array(cachedFavoriteTrackRecordsByID.values)) } catch { Self.logError(error, context: "Favorite update failed") cachedFavoriteTrackRecordsByID = previousFavorites rebuildRows() syncStatus = "Could not update favorites. Try again." } } private func currentOrRegisterDeviceID() async throws -> String { if let existingDeviceID = try await keychainService.loadValue( forKey: Self.deviceIDKey ), !existingDeviceID.isEmpty, let existingDeviceAccessToken = try await keychainService.loadValue( forKey: Self.deviceAccessTokenKey ), !existingDeviceAccessToken.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.deviceAccessToken, forKey: Self.deviceAccessTokenKey ) try await keychainService.save( response.bootstrapToken, forKey: Self.bootstrapTokenKey ) return response.deviceId } private func applyRestoredTracks(_ snapshot: OfflineLibrarySnapshot) { if snapshot.remoteTracks.isEmpty { state = .idle syncStatus = "Sync your remote library to see your tracks here." } else { state = .success syncStatus = "Library restored from local cache." } } private func applySyncedTracks(_ snapshot: OfflineLibrarySnapshot) { if snapshot.remoteTracks.isEmpty { state = .empty syncStatus = "No music was returned for this library." } else { state = .success syncStatus = "Library synced successfully." } } 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 makeRemoteLibrarySyncCursorStore() -> any RemoteLibrarySyncCursorStore { if let store = try? FileRemoteLibrarySyncCursorStore() { return store } return InMemoryRemoteLibrarySyncCursorStore() } 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 static func makeFavoriteTrackStore() -> any FavoriteTrackStore { if let store = try? FileFavoriteTrackStore() { return store } return InMemoryFavoriteTrackStore() } private func reloadFavoriteTracks() async throws { let favoriteTracks = try await favoriteTrackStore.loadFavoriteTracks() var favoritesByTrackID: [String: FavoriteTrackRecord] = [:] for favoriteTrack in favoriteTracks { favoritesByTrackID[favoriteTrack.remoteTrackId] = favoriteTrack } cachedFavoriteTrackRecordsByID = favoritesByTrackID } 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) } ) player.setCatalogTracks(Self.makePlaybackCatalog(from: snapshot.remoteTracks)) rebuildRows() return snapshot } private func rebuildRows() { let favoriteTrackIDs = Set(cachedFavoriteTrackRecordsByID.keys) let filteredSnapshot = OfflineLibrarySnapshot( remoteTracks: cachedRemoteLibraryTracks, availableTracks: cachedAvailableOfflineTracks ).filtered(searchText: searchText) remoteTracks = filteredSnapshot.remoteTracks.map { track in RemoteTrackRowViewData( track: track, nowPlaying: nowPlaying, isFavorite: favoriteTrackIDs.contains(track.remoteTrack.trackId) ) } availableOfflineTracks = filteredSnapshot.availableTracks.map { track in AvailableOfflineTrackRowViewData( track: track, nowPlaying: nowPlaying, isFavorite: favoriteTrackIDs.contains(track.remoteTrackId) ) } if let trackID = nowPlaying.trackID, hasTrackInLibrarySnapshot(trackID) { nowPlayingFavoriteTrackID = trackID isNowPlayingTrackFavorite = favoriteTrackIDs.contains(trackID) } else { nowPlayingFavoriteTrackID = nil isNowPlayingTrackFavorite = false } } private func handleNowPlayingStateChange(_ state: iPhoneNowPlayingState) { nowPlaying = state rebuildRows() } private func handlePlaybackResult(for track: RemoteTrack) { switch nowPlaying.playbackState { case .missingFile: syncStatus = "The downloaded file for \(track.title) is missing. Re-download it to play again." refreshOfflineLibraryInBackground() case .failed: syncStatus = "Playback couldn't start for \(track.title). Try again." case .playing, .paused, .stopped: break } } private func canAttemptPlayback(for trackID: String) -> Bool { guard let remoteTrack = cachedRemoteLibraryTracks.first(where: { $0.remoteTrack.trackId == trackID }) else { return false } return remoteTrack.isFileAvailable || remoteTrack.status == .missing } private func remoteTrackStatus(for trackID: String) -> OfflineLibraryRemoteTrackStatus? { cachedRemoteLibraryTracks.first(where: { $0.remoteTrack.trackId == trackID })?.status } private func nowPlayingDownloadBadge(for trackID: String) -> NowPlayingDownloadBadge { switch remoteTrackStatus(for: trackID) { case .downloaded: return .downloaded case .missing: return .missing default: return .offline } } 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 hasTrackInLibrarySnapshot(_ trackID: String) -> Bool { cachedRemoteTracksByID[trackID] != nil } private func refreshOfflineLibraryInBackground() { Task { @MainActor [weak self] in guard let self else { return } _ = try? await self.reloadOfflineLibrary() } } private static func makePlaybackCatalog( from tracks: [OfflineLibraryRemoteTrack] ) -> [LibraryTrack] { tracks.map { track in LibraryTrack( id: track.remoteTrack.trackId, title: track.remoteTrack.title, artist: track.remoteTrack.artist, durationSeconds: Double(track.remoteTrack.durationSeconds), localFilePath: track.localFilePath, remoteTrackId: track.remoteTrack.trackId ) } } private static func formatPlaybackTime(_ seconds: Double) -> String { guard seconds.isFinite, seconds > 0 else { return "0:00" } let totalSeconds = Int(seconds.rounded(.towardZero)) let minutes = totalSeconds / 60 let remainingSeconds = totalSeconds % 60 return "\(minutes):\(String(format: "%02d", remainingSeconds))" } private static func userFacingPlaybackErrorMessage(for state: iPhoneNowPlayingState) -> String? { switch state.playbackState { case .missingFile: return "This downloaded file is missing. Re-download the track to play it again." case .failed: return "Playback couldn't start. Try again." case .playing, .paused, .stopped: return nil } } private static func logError(_ error: Error, context: String) { print("iPhoneLibraryViewModel \(context): \(error.localizedDescription)") } #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 deviceAccessTokenKey = "velody.iphone.device-access-token" private static let bootstrapTokenKey = "velody.iphone.bootstrap-token" private static let remoteLibraryEmptyMessage = LibrarySectionMessage( title: "No music synced yet", body: "Sync your remote library to see your tracks here.", systemImage: "music.note.list" ) private static let availableOfflineEmptyMessage = LibrarySectionMessage( title: "No offline tracks yet", body: "Download tracks to listen without internet.", systemImage: "arrow.down.circle" ) private static let searchEmptyMessage = LibrarySectionMessage( title: "No matching tracks", body: "Try a different title or artist.", systemImage: "magnifyingglass" ) private static let connectionFailedMessage = LibrarySectionMessage( title: "Connection failed", body: "Could not reach the backend. Check that the server is running and try again.", systemImage: "wifi.exclamationmark" ) } struct RemoteTrackRowViewData: Identifiable, Equatable { let id: String let title: String let artist: String let durationText: String let isFavorite: Bool let status: OfflineLibraryRemoteTrackStatus let statusBadgeTitle: String let statusDetailText: String? let canDownload: Bool let downloadButtonTitle: String let canPlay: Bool let playButtonTitle: String let artworkLocalFilePath: String? init( track: OfflineLibraryRemoteTrack, nowPlaying: iPhoneNowPlayingState, isFavorite: Bool ) { id = track.remoteTrack.trackId title = track.remoteTrack.title artist = track.remoteTrack.artist durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds) self.isFavorite = isFavorite status = track.status statusBadgeTitle = Self.statusBadgeTitle(for: track.status) statusDetailText = Self.statusDetailText(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" 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 statusBadgeTitle(for status: OfflineLibraryRemoteTrackStatus) -> String { switch status { case .notDownloaded: return "Offline" case .downloading: return "Downloading" case .downloaded: return "Downloaded" case .missing: return "Missing" case .failed: return "Failed" } } private static func statusDetailText(for status: OfflineLibraryRemoteTrackStatus) -> String? { switch status { case .missing: return "Downloaded file missing. Re-download to restore offline playback." case .failed: return "Download failed. Try again." case .notDownloaded, .downloading, .downloaded: return nil } } private static func downloadButtonTitle(for status: OfflineLibraryRemoteTrackStatus) -> String { switch status { case .notDownloaded: return "Download" case .downloading: return "Downloading..." case .downloaded: return "Play" 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 isFavorite: Bool let statusBadgeTitle: String let playButtonTitle: String let artworkLocalFilePath: String? init( track: OfflineLibraryTrack, nowPlaying: iPhoneNowPlayingState, isFavorite: Bool ) { id = track.remoteTrackId title = track.title artist = track.artist durationText = RemoteTrackRowViewData.formatDuration(seconds: track.durationSeconds) self.isFavorite = isFavorite statusBadgeTitle = "Downloaded" playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying ? "Pause" : "Play" artworkLocalFilePath = track.localArtworkFilePath } }