1204 lines
37 KiB
Swift
1204 lines
37 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)
|
|
func previousTrack()
|
|
func nextTrack()
|
|
func toggleShuffle()
|
|
func cycleRepeatMode()
|
|
}
|
|
|
|
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 queueTrackIDs: [String]
|
|
var isShuffleEnabled: Bool
|
|
var repeatMode: PlaybackRepeatMode
|
|
var playbackState: iPhonePlaybackState
|
|
var currentTime: Double
|
|
var duration: Double
|
|
var errorMessage: String?
|
|
|
|
static let empty = iPhoneNowPlayingState(
|
|
trackID: nil,
|
|
title: nil,
|
|
artist: nil,
|
|
queueTrackIDs: [],
|
|
isShuffleEnabled: false,
|
|
repeatMode: .off,
|
|
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 LegacyiPhonePlaybackSession: 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),
|
|
!data.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
if let storedSession = try? decoder.decode(PlaybackSessionSnapshot.self, from: data) {
|
|
return storedSession
|
|
}
|
|
|
|
guard let legacySession = try? decoder.decode(LegacyiPhonePlaybackSession.self, from: data) else {
|
|
return nil
|
|
}
|
|
|
|
return PlaybackSessionSnapshot(
|
|
currentTrackID: legacySession.currentTrackID,
|
|
currentTime: legacySession.currentTime
|
|
)
|
|
}
|
|
|
|
func saveSession(_ session: PlaybackSessionSnapshot) {
|
|
guard let currentTrackID = session.currentTrackID,
|
|
!currentTrackID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
else {
|
|
clearSession()
|
|
return
|
|
}
|
|
|
|
let storedSession = PlaybackSessionSnapshot(
|
|
queueTrackIDs: session.queueTrackIDs,
|
|
currentTrackID: currentTrackID,
|
|
currentTime: session.currentTime,
|
|
isShuffleEnabled: session.isShuffleEnabled,
|
|
repeatMode: session.repeatMode
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
func previousTrack() {
|
|
idleStateHint = .paused
|
|
controller.previous()
|
|
apply(controller.nowPlayingState)
|
|
}
|
|
|
|
func nextTrack() {
|
|
idleStateHint = .stopped
|
|
controller.next()
|
|
apply(controller.nowPlayingState)
|
|
}
|
|
|
|
func toggleShuffle() {
|
|
controller.toggleShuffle()
|
|
apply(controller.nowPlayingState)
|
|
}
|
|
|
|
func cycleRepeatMode() {
|
|
controller.cycleRepeatMode()
|
|
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,
|
|
queueTrackIDs: nowPlayingState.queueTrackIDs,
|
|
isShuffleEnabled: nowPlayingState.isShuffleEnabled,
|
|
repeatMode: nowPlayingState.repeatMode,
|
|
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
|
|
|
|
var isShuffleEnabled: Bool {
|
|
nowPlaying.isShuffleEnabled
|
|
}
|
|
|
|
var repeatMode: PlaybackRepeatMode {
|
|
nowPlaying.repeatMode
|
|
}
|
|
|
|
var canGoPrevious: Bool {
|
|
guard nowPlaying.hasTrack else {
|
|
return false
|
|
}
|
|
|
|
if nowPlaying.currentTime > 0.25 {
|
|
return true
|
|
}
|
|
|
|
return playbackQueueForNowPlaying?.previousTrackID() != nil
|
|
}
|
|
|
|
var canGoNext: Bool {
|
|
guard nowPlaying.hasTrack else {
|
|
return false
|
|
}
|
|
|
|
return playbackQueueForNowPlaying?.nextTrackID() != nil
|
|
}
|
|
|
|
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 previousTrack() {
|
|
guard nowPlaying.hasTrack else {
|
|
return
|
|
}
|
|
|
|
player.previousTrack()
|
|
handleCurrentPlaybackResult()
|
|
}
|
|
|
|
func nextTrack() {
|
|
guard nowPlaying.hasTrack else {
|
|
return
|
|
}
|
|
|
|
player.nextTrack()
|
|
handleCurrentPlaybackResult()
|
|
}
|
|
|
|
func toggleShuffle() {
|
|
guard !nowPlaying.queueTrackIDs.isEmpty else {
|
|
return
|
|
}
|
|
|
|
player.toggleShuffle()
|
|
}
|
|
|
|
func cycleRepeatMode() {
|
|
guard !nowPlaying.queueTrackIDs.isEmpty else {
|
|
return
|
|
}
|
|
|
|
player.cycleRepeatMode()
|
|
}
|
|
|
|
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 var playbackQueueForNowPlaying: PlaybackQueue? {
|
|
guard !nowPlaying.queueTrackIDs.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
return PlaybackQueue(
|
|
trackIDs: nowPlaying.queueTrackIDs,
|
|
currentTrackID: nowPlaying.trackID,
|
|
queuedTrackIDs: nowPlaying.queueTrackIDs,
|
|
isShuffleEnabled: nowPlaying.isShuffleEnabled,
|
|
repeatMode: nowPlaying.repeatMode
|
|
)
|
|
}
|
|
|
|
private func handleCurrentPlaybackResult() {
|
|
guard let trackID = nowPlaying.trackID,
|
|
let track = cachedRemoteTracksByID[trackID]
|
|
else {
|
|
return
|
|
}
|
|
|
|
handlePlaybackResult(for: track)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|