velody/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift
2026-06-01 00:24:16 +02:00

936 lines
29 KiB
Swift

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?
}
@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 = .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 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: nowPlaying.errorMessage
)
}
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)
let store = Self.makeRemoteLibraryStore()
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
let audioFileStore = Self.makeOfflineAudioFileStore()
let artworkStore = Self.makeArtworkStore()
let favoriteTrackStore = Self.makeFavoriteTrackStore()
let repository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: store
)
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 {
syncStatus += " Favorites could not be restored: \(favoriteRestoreError.localizedDescription)"
}
} 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
}
guard canAttemptPlayback(for: track.trackId) else {
if remoteTrackStatus(for: track.trackId) == .missing {
syncStatus = "The downloaded file for \(track.title) is missing."
} 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 {
cachedFavoriteTrackRecordsByID = previousFavorites
rebuildRows()
syncStatus = "Favorite update failed: \(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(_ 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 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."
refreshOfflineLibraryInBackground()
case .failed:
syncStatus = "Playback failed for \(track.title): \(nowPlaying.errorMessage ?? "Unknown error.")"
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))"
}
#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 isFavorite: Bool
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,
isFavorite: Bool
) {
id = track.remoteTrack.trackId
title = track.remoteTrack.title
artist = track.remoteTrack.artist
durationText = Self.formatDuration(seconds: track.remoteTrack.durationSeconds)
self.isFavorite = isFavorite
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 isFavorite: Bool
let remoteTrackID: String
let assetID: 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
remoteTrackID = track.remoteTrackId
assetID = track.assetId
playButtonTitle = nowPlaying.trackID == track.remoteTrackId && nowPlaying.isPlaying
? "Pause"
: "Play"
artworkLocalFilePath = track.localArtworkFilePath
}
}