Expose iPhone playback controls

This commit is contained in:
diyaa
2026-06-16 02:11:24 +02:00
parent 295c6c1d9b
commit e147ce38be
7 changed files with 993 additions and 131 deletions
@@ -1,6 +1,7 @@
import Foundation
import VelodyDomain
import VelodyNetworking
import VelodyPlayback
import VelodyPersistence
import VelodySync
import VelodyUtilities
@@ -15,57 +16,22 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
var missingTrackIDs = Set<String>()
private var catalogTracksByID: [String: LibraryTrack] = [:]
private var queue = PlaybackQueue()
func setCatalogTracks(_ tracks: [LibraryTrack]) {
catalogTracksByID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) })
guard let trackID = state.trackID,
let currentTrack = catalogTracksByID[trackID]
else {
return
}
state = Self.makeState(
for: currentTrack,
playbackState: state.playbackState,
currentTime: state.currentTime,
errorMessage: state.errorMessage
queue.replaceTrackIDs(
tracks.map(\.id),
currentTrackID: state.trackID,
queuedTrackIDs: state.queueTrackIDs
)
refreshState()
onStateChange?(state)
}
func play(trackID: String) {
guard let track = catalogTracksByID[trackID] else {
return
}
let currentTime = state.trackID == trackID ? state.currentTime : 0
if missingTrackIDs.contains(trackID) ||
track.localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
state = Self.makeState(
for: track,
playbackState: .missingFile,
currentTime: currentTime,
errorMessage: "The local file could not be found: \(track.localFilePath)"
)
} else if failingTrackIDs.contains(trackID) {
state = Self.makeState(
for: track,
playbackState: .failed,
currentTime: currentTime,
errorMessage: "Playback could not be started."
)
} else {
state = Self.makeState(
for: track,
playbackState: .playing,
currentTime: currentTime,
errorMessage: nil
)
}
onStateChange?(state)
startPlayback(trackID: trackID, currentTime: currentTime)
}
func pause() {
@@ -75,6 +41,7 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
state = Self.makeState(
for: currentTrack,
queue: queue,
playbackState: .paused,
currentTime: state.currentTime,
errorMessage: nil
@@ -84,13 +51,14 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
func stop() {
guard let currentTrack else {
state = .empty
state = Self.makeEmptyState(queue: queue)
onStateChange?(state)
return
}
state = Self.makeState(
for: currentTrack,
queue: queue,
playbackState: .stopped,
currentTime: 0,
errorMessage: nil
@@ -105,6 +73,7 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
state = Self.makeState(
for: currentTrack,
queue: queue,
playbackState: state.playbackState,
currentTime: time,
errorMessage: state.errorMessage
@@ -112,6 +81,41 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
onStateChange?(state)
}
func previousTrack() {
if state.currentTime > 5 {
seek(to: 0)
return
}
guard let previousTrackID = queue.moveToPreviousTrack() else {
seek(to: 0)
return
}
startPlayback(trackID: previousTrackID, currentTime: 0)
}
func nextTrack() {
guard let nextTrackID = queue.advanceToNextTrack() else {
stop()
return
}
startPlayback(trackID: nextTrackID, currentTime: 0)
}
func toggleShuffle() {
queue.toggleShuffle()
refreshState()
onStateChange?(state)
}
func cycleRepeatMode() {
queue.cycleRepeatMode()
refreshState()
onStateChange?(state)
}
func advanceProgress(by timeDelta: Double) {
guard state.isPlaying,
let currentTrack
@@ -131,6 +135,7 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
state = Self.makeState(
for: currentTrack,
queue: queue,
playbackState: playbackState,
currentTime: updatedTime,
errorMessage: nil
@@ -138,6 +143,43 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
onStateChange?(state)
}
private func startPlayback(trackID: String, currentTime: Double) {
guard let track = catalogTracksByID[trackID] else {
return
}
queue.selectTrack(trackID)
if missingTrackIDs.contains(trackID) ||
track.localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
state = Self.makeState(
for: track,
queue: queue,
playbackState: .missingFile,
currentTime: currentTime,
errorMessage: "The local file could not be found: \(track.localFilePath)"
)
} else if failingTrackIDs.contains(trackID) {
state = Self.makeState(
for: track,
queue: queue,
playbackState: .failed,
currentTime: currentTime,
errorMessage: "Playback could not be started."
)
} else {
state = Self.makeState(
for: track,
queue: queue,
playbackState: .playing,
currentTime: currentTime,
errorMessage: nil
)
}
onStateChange?(state)
}
private var currentTrack: LibraryTrack? {
guard let trackID = state.trackID else {
return nil
@@ -146,8 +188,24 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
return catalogTracksByID[trackID]
}
private func refreshState() {
guard let currentTrack else {
state = Self.makeEmptyState(queue: queue)
return
}
state = Self.makeState(
for: currentTrack,
queue: queue,
playbackState: state.playbackState,
currentTime: state.currentTime,
errorMessage: state.errorMessage
)
}
private static func makeState(
for track: LibraryTrack,
queue: PlaybackQueue,
playbackState: iPhonePlaybackState,
currentTime: Double,
errorMessage: String?
@@ -165,12 +223,30 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
trackID: track.id,
title: track.title,
artist: track.artist,
queueTrackIDs: queue.queuedTrackIDs,
isShuffleEnabled: queue.isShuffleEnabled,
repeatMode: queue.repeatMode,
playbackState: playbackState,
currentTime: clampedTime,
duration: duration,
errorMessage: errorMessage
)
}
private static func makeEmptyState(queue: PlaybackQueue) -> iPhoneNowPlayingState {
iPhoneNowPlayingState(
trackID: nil,
title: nil,
artist: nil,
queueTrackIDs: queue.queuedTrackIDs,
isShuffleEnabled: queue.isShuffleEnabled,
repeatMode: queue.repeatMode,
playbackState: .stopped,
currentTime: 0,
duration: 0,
errorMessage: nil
)
}
}
private actor TestRemoteLibraryRepository: RemoteLibraryRepository {