487 lines
15 KiB
Swift
487 lines
15 KiB
Swift
import Foundation
|
|
import Observation
|
|
import VelodyDomain
|
|
import VelodyNetworking
|
|
import VelodyPersistence
|
|
import VelodySync
|
|
import VelodyUtilities
|
|
#if canImport(UIKit)
|
|
import AVFoundation
|
|
import UIKit
|
|
#endif
|
|
|
|
@MainActor
|
|
protocol iPhoneLocalAudioPlaying: AnyObject {
|
|
var onStateChange: ((iPhoneNowPlayingState) -> Void)? { get set }
|
|
var state: iPhoneNowPlayingState { get }
|
|
|
|
func play(
|
|
trackID: String,
|
|
title: String,
|
|
artist: String,
|
|
fileURL: URL
|
|
) throws
|
|
func resume() throws
|
|
func pause()
|
|
}
|
|
|
|
struct iPhoneNowPlayingState: Equatable {
|
|
var trackID: String?
|
|
var title: String?
|
|
var artist: String?
|
|
var isPlaying: Bool
|
|
var errorMessage: String?
|
|
}
|
|
|
|
@MainActor
|
|
final class iPhoneLocalAudioPlayer: NSObject, iPhoneLocalAudioPlaying, AVAudioPlayerDelegate {
|
|
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
|
|
private(set) var state = iPhoneNowPlayingState(
|
|
trackID: nil,
|
|
title: nil,
|
|
artist: nil,
|
|
isPlaying: false,
|
|
errorMessage: nil
|
|
) {
|
|
didSet {
|
|
onStateChange?(state)
|
|
}
|
|
}
|
|
|
|
private var audioPlayer: AVAudioPlayer?
|
|
|
|
func play(
|
|
trackID: String,
|
|
title: String,
|
|
artist: String,
|
|
fileURL: URL
|
|
) throws {
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
state = iPhoneNowPlayingState(
|
|
trackID: trackID,
|
|
title: title,
|
|
artist: artist,
|
|
isPlaying: false,
|
|
errorMessage: "The downloaded file could not be found."
|
|
)
|
|
throw NSError(
|
|
domain: "VelodyiPhonePlayback",
|
|
code: 1,
|
|
userInfo: [NSLocalizedDescriptionKey: "The downloaded file could not be found."]
|
|
)
|
|
}
|
|
|
|
do {
|
|
try configureAudioSession()
|
|
audioPlayer?.stop()
|
|
let audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
|
|
audioPlayer.delegate = self
|
|
audioPlayer.prepareToPlay()
|
|
self.audioPlayer = audioPlayer
|
|
|
|
guard audioPlayer.play() else {
|
|
state = iPhoneNowPlayingState(
|
|
trackID: trackID,
|
|
title: title,
|
|
artist: artist,
|
|
isPlaying: false,
|
|
errorMessage: "Playback could not be started."
|
|
)
|
|
throw NSError(
|
|
domain: "VelodyiPhonePlayback",
|
|
code: 2,
|
|
userInfo: [NSLocalizedDescriptionKey: "Playback could not be started."]
|
|
)
|
|
}
|
|
|
|
state = iPhoneNowPlayingState(
|
|
trackID: trackID,
|
|
title: title,
|
|
artist: artist,
|
|
isPlaying: true,
|
|
errorMessage: nil
|
|
)
|
|
} catch {
|
|
if state.trackID == nil {
|
|
state = iPhoneNowPlayingState(
|
|
trackID: trackID,
|
|
title: title,
|
|
artist: artist,
|
|
isPlaying: false,
|
|
errorMessage: "The downloaded audio file could not be opened."
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func resume() throws {
|
|
guard let audioPlayer else {
|
|
state.errorMessage = "No downloaded track is loaded."
|
|
throw NSError(
|
|
domain: "VelodyiPhonePlayback",
|
|
code: 3,
|
|
userInfo: [NSLocalizedDescriptionKey: "No downloaded track is loaded."]
|
|
)
|
|
}
|
|
|
|
guard audioPlayer.play() else {
|
|
state.errorMessage = "Playback could not be resumed."
|
|
throw NSError(
|
|
domain: "VelodyiPhonePlayback",
|
|
code: 4,
|
|
userInfo: [NSLocalizedDescriptionKey: "Playback could not be resumed."]
|
|
)
|
|
}
|
|
|
|
state.isPlaying = true
|
|
state.errorMessage = nil
|
|
}
|
|
|
|
func pause() {
|
|
audioPlayer?.pause()
|
|
state.isPlaying = false
|
|
}
|
|
|
|
nonisolated func audioPlayerDidFinishPlaying(
|
|
_ player: AVAudioPlayer,
|
|
successfully flag: Bool
|
|
) {
|
|
guard flag else {
|
|
return
|
|
}
|
|
|
|
Task { @MainActor [weak self] in
|
|
self?.state.isPlaying = false
|
|
}
|
|
}
|
|
|
|
private func configureAudioSession() throws {
|
|
let session = AVAudioSession.sharedInstance()
|
|
try session.setCategory(.playback, mode: .default)
|
|
try session.setActive(true)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class iPhoneLibraryViewModel {
|
|
enum ViewState: Equatable {
|
|
case idle
|
|
case loading
|
|
case success
|
|
case empty
|
|
case networkError(String)
|
|
}
|
|
|
|
var remoteTracks: [RemoteTrackRowViewData] = []
|
|
var syncStatus = "Remote library not synced yet."
|
|
var state: ViewState = .idle
|
|
var nowPlaying = iPhoneNowPlayingState(
|
|
trackID: nil,
|
|
title: nil,
|
|
artist: nil,
|
|
isPlaying: false,
|
|
errorMessage: nil
|
|
)
|
|
|
|
private let environment: ServerEnvironment
|
|
private let apiClient: any VelodyAPIClient
|
|
private let syncService: RemoteLibrarySyncService
|
|
private let keychainService: any KeychainService
|
|
private let player: any iPhoneLocalAudioPlaying
|
|
private var cachedRemoteTracks: [RemoteTrack] = []
|
|
private var downloadStatesByTrackID: [String: RemoteTrackDownloadState] = [:]
|
|
private var hasLoaded = false
|
|
|
|
init(
|
|
player: (any iPhoneLocalAudioPlaying)? = nil,
|
|
keychainService: any KeychainService = SystemKeychainService(
|
|
service: "de.diyaa.velody.iphone"
|
|
)
|
|
) {
|
|
let environment = ServerEnvironment(
|
|
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
|
appVersion: "0.1.0"
|
|
)
|
|
let apiClient = URLSessionVelodyAPIClient(environment: environment)
|
|
let store = Self.makeRemoteLibraryStore()
|
|
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
|
let audioFileStore = Self.makeOfflineAudioFileStore()
|
|
|
|
self.environment = environment
|
|
self.apiClient = apiClient
|
|
self.keychainService = keychainService
|
|
self.player = player ?? iPhoneLocalAudioPlayer()
|
|
self.syncService = RemoteLibrarySyncService(
|
|
repository: DefaultRemoteLibraryRepository(
|
|
apiClient: apiClient,
|
|
store: store
|
|
),
|
|
downloadStateStore: downloadStateStore,
|
|
audioFileStore: audioFileStore
|
|
)
|
|
self.player.onStateChange = { [weak self] state in
|
|
self?.handleNowPlayingStateChange(state)
|
|
}
|
|
}
|
|
|
|
func loadIfNeeded() async {
|
|
guard !hasLoaded else { return }
|
|
hasLoaded = true
|
|
|
|
do {
|
|
cachedRemoteTracks = try await syncService.loadCachedRemoteTracks()
|
|
downloadStatesByTrackID = try await loadDownloadStateDictionary()
|
|
rebuildRows()
|
|
applyRestoredTracks(cachedRemoteTracks)
|
|
} catch {
|
|
state = .idle
|
|
syncStatus = "Failed to load cached remote library: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
func refreshSync() async {
|
|
state = .loading
|
|
syncStatus = "Syncing remote library..."
|
|
|
|
do {
|
|
let deviceId = try await currentOrRegisterDeviceID()
|
|
cachedRemoteTracks = try await syncService.syncRemoteLibrary(deviceId: deviceId)
|
|
downloadStatesByTrackID = try await loadDownloadStateDictionary()
|
|
rebuildRows()
|
|
applySyncedTracks(cachedRemoteTracks)
|
|
} catch {
|
|
state = .networkError("Remote library sync failed.")
|
|
syncStatus = "Remote library sync failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
func downloadTrack(trackID: String) async {
|
|
guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else {
|
|
return
|
|
}
|
|
|
|
let currentState = downloadStatesByTrackID[trackID]
|
|
if currentState?.downloadStatus == .downloaded {
|
|
return
|
|
}
|
|
|
|
downloadStatesByTrackID[trackID] = RemoteTrackDownloadState(
|
|
remoteTrackId: track.trackId,
|
|
assetId: track.assetId,
|
|
localFilePath: currentState?.localFilePath ?? "",
|
|
downloadedAt: currentState?.downloadedAt,
|
|
downloadStatus: .downloading,
|
|
lastDownloadError: nil
|
|
)
|
|
rebuildRows()
|
|
syncStatus = "Downloading \(track.title)..."
|
|
|
|
do {
|
|
let deviceId = try await currentOrRegisterDeviceID()
|
|
let downloadState = try await syncService.downloadTrack(track, deviceId: deviceId)
|
|
downloadStatesByTrackID[track.trackId] = downloadState
|
|
rebuildRows()
|
|
syncStatus = "Downloaded \(track.title)."
|
|
} catch {
|
|
downloadStatesByTrackID = (try? await loadDownloadStateDictionary()) ?? downloadStatesByTrackID
|
|
rebuildRows()
|
|
syncStatus = "Download failed for \(track.title): \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
func togglePlayback(trackID: String) {
|
|
guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else {
|
|
return
|
|
}
|
|
|
|
guard let downloadState = downloadStatesByTrackID[track.trackId],
|
|
downloadState.downloadStatus == .downloaded,
|
|
downloadState.hasLocalFile
|
|
else {
|
|
syncStatus = "Download the track before playing it offline."
|
|
return
|
|
}
|
|
|
|
let fileURL = URL(fileURLWithPath: downloadState.localFilePath)
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
syncStatus = "The downloaded file for \(track.title) is missing."
|
|
return
|
|
}
|
|
|
|
do {
|
|
if nowPlaying.trackID == track.trackId {
|
|
if nowPlaying.isPlaying {
|
|
player.pause()
|
|
} else {
|
|
try player.resume()
|
|
}
|
|
} else {
|
|
try player.play(
|
|
trackID: track.trackId,
|
|
title: track.title,
|
|
artist: track.artist,
|
|
fileURL: fileURL
|
|
)
|
|
}
|
|
} catch {
|
|
syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)"
|
|
}
|
|
}
|
|
|
|
private func currentOrRegisterDeviceID() async throws -> String {
|
|
if let existingDeviceID = try await keychainService.loadValue(
|
|
forKey: Self.deviceIDKey
|
|
), !existingDeviceID.isEmpty {
|
|
return existingDeviceID
|
|
}
|
|
|
|
let response = try await apiClient.registerDevice(
|
|
DeviceRegistrationPayload(
|
|
platform: .iphone,
|
|
deviceName: Self.currentDeviceName,
|
|
appVersion: environment.appVersion
|
|
)
|
|
)
|
|
|
|
try await keychainService.save(response.deviceId, forKey: Self.deviceIDKey)
|
|
try await keychainService.save(
|
|
response.bootstrapToken,
|
|
forKey: Self.bootstrapTokenKey
|
|
)
|
|
|
|
return response.deviceId
|
|
}
|
|
|
|
private func applyRestoredTracks(_ tracks: [RemoteTrack]) {
|
|
if tracks.isEmpty {
|
|
state = .idle
|
|
syncStatus = "Tap Sync Remote Library to load remote metadata."
|
|
} else {
|
|
state = .success
|
|
syncStatus = "Restored \(tracks.count) cached remote track(s)."
|
|
}
|
|
}
|
|
|
|
private func applySyncedTracks(_ tracks: [RemoteTrack]) {
|
|
if tracks.isEmpty {
|
|
state = .empty
|
|
syncStatus = "Remote library is empty."
|
|
} else {
|
|
state = .success
|
|
syncStatus = "Sync Remote Library completed. Remote tracks: \(tracks.count)."
|
|
}
|
|
}
|
|
|
|
private static func makeRemoteLibraryStore() -> any RemoteLibraryStore {
|
|
if let store = try? FileRemoteLibraryStore() {
|
|
return store
|
|
}
|
|
|
|
return InMemoryRemoteLibraryStore()
|
|
}
|
|
|
|
private static func makeRemoteTrackDownloadStateStore() -> any RemoteTrackDownloadStateStore {
|
|
if let store = try? FileRemoteTrackDownloadStateStore() {
|
|
return store
|
|
}
|
|
|
|
return InMemoryRemoteTrackDownloadStateStore()
|
|
}
|
|
|
|
private static func makeOfflineAudioFileStore() -> any OfflineAudioFileStore {
|
|
if let store = try? FileOfflineAudioFileStore() {
|
|
return store
|
|
}
|
|
|
|
return InMemoryOfflineAudioFileStore()
|
|
}
|
|
|
|
private func loadDownloadStateDictionary() async throws -> [String: RemoteTrackDownloadState] {
|
|
Dictionary(
|
|
uniqueKeysWithValues: try await syncService
|
|
.loadDownloadStates()
|
|
.map { ($0.remoteTrackId, $0) }
|
|
)
|
|
}
|
|
|
|
private func rebuildRows() {
|
|
remoteTracks = cachedRemoteTracks.map { track in
|
|
RemoteTrackRowViewData(
|
|
track: track,
|
|
downloadState: downloadStatesByTrackID[track.trackId],
|
|
nowPlaying: nowPlaying
|
|
)
|
|
}
|
|
}
|
|
|
|
private func handleNowPlayingStateChange(_ state: iPhoneNowPlayingState) {
|
|
nowPlaying = state
|
|
rebuildRows()
|
|
}
|
|
|
|
#if canImport(UIKit)
|
|
private static var currentDeviceName: String {
|
|
UIDevice.current.name
|
|
}
|
|
#else
|
|
private static let currentDeviceName = "Velody iPhone"
|
|
#endif
|
|
|
|
private static let deviceIDKey = "velody.iphone.device-id"
|
|
private static let bootstrapTokenKey = "velody.iphone.bootstrap-token"
|
|
}
|
|
|
|
struct RemoteTrackRowViewData: Identifiable, Equatable {
|
|
let id: String
|
|
let title: String
|
|
let artist: String
|
|
let durationText: String
|
|
let remoteTrackID: String
|
|
let statusText: String
|
|
let canDownload: Bool
|
|
let canPlay: Bool
|
|
let playButtonTitle: String
|
|
let lastDownloadError: String?
|
|
|
|
init(
|
|
track: RemoteTrack,
|
|
downloadState: RemoteTrackDownloadState?,
|
|
nowPlaying: iPhoneNowPlayingState
|
|
) {
|
|
id = track.trackId
|
|
title = track.title
|
|
artist = track.artist
|
|
durationText = Self.formatDuration(seconds: track.durationSeconds)
|
|
remoteTrackID = track.trackId
|
|
let status = downloadState?.downloadStatus ?? .notDownloaded
|
|
statusText = Self.statusText(for: status)
|
|
canDownload = status == .notDownloaded || status == .failed
|
|
canPlay = status == .downloaded
|
|
playButtonTitle = nowPlaying.trackID == track.trackId && nowPlaying.isPlaying
|
|
? "Pause"
|
|
: "Play"
|
|
lastDownloadError = downloadState?.lastDownloadError
|
|
}
|
|
|
|
private static func formatDuration(seconds: Int) -> String {
|
|
let minutes = seconds / 60
|
|
let remainingSeconds = seconds % 60
|
|
return "\(minutes):\(String(format: "%02d", remainingSeconds))"
|
|
}
|
|
|
|
private static func statusText(for status: RemoteTrackDownloadStatus) -> String {
|
|
switch status {
|
|
case .notDownloaded:
|
|
return "Not downloaded"
|
|
case .downloading:
|
|
return "Downloading"
|
|
case .downloaded:
|
|
return "Downloaded"
|
|
case .failed:
|
|
return "Failed"
|
|
}
|
|
}
|
|
}
|