mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 14:44:00 +00:00
Compare commits
No commits in common. "7bf17afc427fdadc83bc8edbed0f6672002b47f0" and "df3a6dcbd275299888ba1e591f7f5d92fe5ea461" have entirely different histories.
7bf17afc42
...
df3a6dcbd2
@ -158,7 +158,6 @@ val sharedModules = module {
|
||||
libraryRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -174,7 +173,6 @@ val sharedModules = module {
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -198,7 +196,6 @@ val sharedModules = module {
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -211,7 +208,6 @@ val sharedModules = module {
|
||||
albumRepository = get(),
|
||||
playlistRepository = get(),
|
||||
savedTracksRepository = get(),
|
||||
artistRepository = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
@ -223,13 +219,13 @@ val sharedModules = module {
|
||||
singleOf(::LocalServer) withOptions {
|
||||
createdAtStart()
|
||||
}
|
||||
single { RemoteControlHandler(get(), get(), get(), get()) }
|
||||
single { RemoteControlHandler(get(), get(), get()) }
|
||||
single { RemoteControlClient() }
|
||||
singleOf(::DeviceDiscoveryService)
|
||||
single { RemoteControlService(get(), get(), get()) } withOptions {
|
||||
createdAtStart()
|
||||
}
|
||||
single { RemotePlaybackController(get(), get(), get(), get()) }
|
||||
single { RemotePlaybackController() }
|
||||
single { JamSessionService(get(), get()) }
|
||||
singleOf(::JamDeepLinkService)
|
||||
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||
|
||||
@ -22,7 +22,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
||||
import dev.krtirtho.spotube.modules.artist.ArtistRepository
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
@ -31,7 +30,6 @@ class CollectionPlaybackHelper(
|
||||
private val albumRepository: AlbumRepository,
|
||||
private val playlistRepository: PlaylistRepository,
|
||||
private val savedTracksRepository: SavedTracksRepository,
|
||||
private val artistRepository: ArtistRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) {
|
||||
@ -58,13 +56,6 @@ class CollectionPlaybackHelper(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun playAlbumNext(albumId: String) {
|
||||
val entries = fetchAllAlbumTracks(albumId)
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun playAlbumFromTrack(albumId: String, track: MetadataTrack) {
|
||||
val entries = fetchAllAlbumTracks(albumId)
|
||||
if (entries.isEmpty()) return
|
||||
@ -113,13 +104,6 @@ class CollectionPlaybackHelper(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun playPlaylistNext(playlistId: String) {
|
||||
val entries = fetchAllPlaylistTracks(playlistId)
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun playPlaylistFromTrack(playlistId: String, track: MetadataTrack) {
|
||||
val entries = fetchAllPlaylistTracks(playlistId)
|
||||
if (entries.isEmpty()) return
|
||||
@ -158,32 +142,6 @@ class CollectionPlaybackHelper(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun playArtistTopTracks(artistId: String) {
|
||||
val entries = fetchArtistTopTracks(artistId)
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.load(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addArtistTopTracksToQueue(artistId: String) {
|
||||
val entries = fetchArtistTopTracks(artistId)
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun playArtistTopTracksNext(artistId: String) {
|
||||
val entries = fetchArtistTopTracks(artistId)
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addSavedTracksToQueue() {
|
||||
val entries = fetchAllSavedTracks()
|
||||
if (entries.isNotEmpty()) {
|
||||
@ -285,15 +243,6 @@ class CollectionPlaybackHelper(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchArtistTopTracks(artistId: String): List<QueueEntry> {
|
||||
val tracks = artistRepository.topTracks(artistId).orEmpty()
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
return tracks
|
||||
.filter { track -> !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) }
|
||||
.map { track -> QueueEntry.StreamingTrack(track = track, url = "") }
|
||||
}
|
||||
|
||||
private fun isTrackBlacklisted(
|
||||
track: MetadataTrack,
|
||||
blacklistedTrackIds: Set<String>,
|
||||
|
||||
@ -33,8 +33,11 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
@ -66,11 +69,8 @@ class RemoteControlClient {
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _latestPlayerState = MutableStateFlow<RemoteControlEvent.PlayerState?>(null)
|
||||
val latestPlayerState: StateFlow<RemoteControlEvent.PlayerState?> = _latestPlayerState.asStateFlow()
|
||||
|
||||
private val _latestQueue = MutableStateFlow<RemoteControlEvent.QueueUpdated?>(null)
|
||||
val latestQueue: StateFlow<RemoteControlEvent.QueueUpdated?> = _latestQueue.asStateFlow()
|
||||
private val _stateUpdates = MutableSharedFlow<RemoteControlEvent>(extraBufferCapacity = 32)
|
||||
val stateUpdates: SharedFlow<RemoteControlEvent> = _stateUpdates.asSharedFlow()
|
||||
|
||||
suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) {
|
||||
if (_connectionState.value is ConnectionState.Connected) {
|
||||
@ -101,16 +101,13 @@ class RemoteControlClient {
|
||||
|
||||
private suspend fun receiveLoop(host: String, port: Int) {
|
||||
val currentSession = session ?: return
|
||||
logger.d { "Starting receive loop for $host:$port" }
|
||||
try {
|
||||
for (frame in currentSession.incoming) {
|
||||
when (frame) {
|
||||
is Frame.Text -> {
|
||||
val text = frame.readText()
|
||||
logger.d { "Received frame: $text" }
|
||||
try {
|
||||
val event = json.decodeFromString(RemoteControlEvent.serializer(), text)
|
||||
logger.d { "Parsed event: $event" }
|
||||
when (event) {
|
||||
is RemoteControlEvent.Connected -> {
|
||||
logger.i { "Connection authorized by server" }
|
||||
@ -121,17 +118,9 @@ class RemoteControlClient {
|
||||
// Keep showing connecting state
|
||||
}
|
||||
else -> {
|
||||
// Only store state updates after connection is established
|
||||
// Only emit state updates after connection is established
|
||||
if (_connectionState.value is ConnectionState.Connected) {
|
||||
when (event) {
|
||||
is RemoteControlEvent.PlayerState -> {
|
||||
_latestPlayerState.value = event
|
||||
}
|
||||
is RemoteControlEvent.QueueUpdated -> {
|
||||
_latestQueue.value = event
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
_stateUpdates.emit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -147,7 +136,6 @@ class RemoteControlClient {
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
logger.d { "Receive loop exited normally" }
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Error in receive loop" }
|
||||
_connectionState.value = ConnectionState.Error(e.message ?: "Connection lost")
|
||||
|
||||
@ -23,7 +23,6 @@ import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState as AudioPlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||
import io.ktor.server.websocket.WebSocketServerSession
|
||||
import io.ktor.websocket.CloseReason
|
||||
@ -31,14 +30,8 @@ import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.close
|
||||
import io.ktor.websocket.readText
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.time.TimeSource
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
@ -48,7 +41,6 @@ class RemoteControlHandler(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
) : KoinComponent {
|
||||
val logger by injectLogger<RemoteControlHandler>()
|
||||
|
||||
@ -82,7 +74,7 @@ class RemoteControlHandler(
|
||||
val waitingMessage = RemoteControlEvent.WaitingForPermission(
|
||||
"Waiting for permission from $deviceName..."
|
||||
)
|
||||
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), waitingMessage)))
|
||||
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.WaitingForPermission.serializer(), waitingMessage)))
|
||||
|
||||
val request = ConnectionRequest(
|
||||
deviceId = deviceId ?: "unknown",
|
||||
@ -107,29 +99,14 @@ class RemoteControlHandler(
|
||||
}
|
||||
|
||||
// Send connected message
|
||||
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Connected)))
|
||||
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.Connected.serializer(), RemoteControlEvent.Connected)))
|
||||
logger.i { "Remote control connection established from $deviceName ($deviceId)" }
|
||||
|
||||
// Broadcast initial player state so the controller shows current track info
|
||||
broadcastState(session)
|
||||
broadcastQueue(session)
|
||||
|
||||
try {
|
||||
// Periodically push player state so the controller's progress bar
|
||||
// stays in sync even when no commands are being sent.
|
||||
coroutineScope {
|
||||
launch {
|
||||
while (isActive) {
|
||||
delay(1_000)
|
||||
broadcastState(session)
|
||||
}
|
||||
}
|
||||
handleControlLoop(session)
|
||||
}
|
||||
handleControlLoop(session)
|
||||
} catch (e: Exception) {
|
||||
logger.w(e) { "Error in remote control session" }
|
||||
} finally {
|
||||
logger.d { "Closing session in finally block" }
|
||||
session.close()
|
||||
}
|
||||
}
|
||||
@ -152,11 +129,9 @@ class RemoteControlHandler(
|
||||
}
|
||||
|
||||
private suspend fun handleControlLoop(session: WebSocketServerSession) {
|
||||
logger.d { "Starting control loop for session" }
|
||||
for (frame in session.incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
logger.d { "Received command: $text" }
|
||||
try {
|
||||
val envelope = json.decodeFromString(CommandEnvelope.serializer(), text)
|
||||
handleCommand(session, envelope)
|
||||
@ -166,25 +141,21 @@ class RemoteControlHandler(
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.d { "Control loop exited normally" }
|
||||
}
|
||||
|
||||
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
|
||||
when (val command = envelope.command) {
|
||||
is RemoteControlCommand.Play -> {
|
||||
handleCollectionSource(command.source, RemoteCollectionAction.Play)
|
||||
logger.d { "Remote play request: ${command.source} (playback source not yet implemented)" }
|
||||
}
|
||||
is RemoteControlCommand.Pause -> {
|
||||
audioPlayer.pause()
|
||||
}
|
||||
is RemoteControlCommand.TogglePlayPause -> {
|
||||
val isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING
|
||||
if (isPlaying) {
|
||||
if (audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) {
|
||||
audioPlayer.pause()
|
||||
waitForPlaybackState(expectPlaying = false)
|
||||
} else {
|
||||
audioPlayer.play()
|
||||
waitForPlaybackState(expectPlaying = true)
|
||||
}
|
||||
}
|
||||
is RemoteControlCommand.Seek -> {
|
||||
@ -215,133 +186,26 @@ class RemoteControlHandler(
|
||||
audioPlayer.loop(loopState)
|
||||
}
|
||||
is RemoteControlCommand.AddToQueue -> {
|
||||
handleCollectionSource(command.source, RemoteCollectionAction.AddToQueue)
|
||||
}
|
||||
is RemoteControlCommand.PlayNext -> {
|
||||
handleCollectionSource(command.source, RemoteCollectionAction.PlayNext)
|
||||
}
|
||||
is RemoteControlCommand.PlayTrack -> {
|
||||
audioPlayerQueue.load(
|
||||
entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
}
|
||||
is RemoteControlCommand.AddTrackToQueue -> {
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = ""))
|
||||
}
|
||||
is RemoteControlCommand.PlayTrackNext -> {
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = command.track, url = "")))
|
||||
}
|
||||
is RemoteControlCommand.AddTracksToQueue -> {
|
||||
val entries = command.tracks.map { track ->
|
||||
QueueEntry.StreamingTrack(track = track, url = "")
|
||||
}
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
is RemoteControlCommand.PlayTracksNext -> {
|
||||
val entries = command.tracks.map { track ->
|
||||
QueueEntry.StreamingTrack(track = track, url = "")
|
||||
}
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
is RemoteControlCommand.PlayIndex -> {
|
||||
audioPlayerQueue.jumpTo(command.index)
|
||||
logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" }
|
||||
}
|
||||
is RemoteControlCommand.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.queueFlow.value
|
||||
val entry = queue.firstOrNull { candidate ->
|
||||
when (candidate) {
|
||||
is QueueEntry.StreamingTrack -> candidate.track.id == command.mediaUrl
|
||||
is QueueEntry.LocalTrack -> candidate.url == command.mediaUrl
|
||||
}
|
||||
}
|
||||
if (entry != null) {
|
||||
audioPlayerQueue.removeFromQueue(entry)
|
||||
} else {
|
||||
logger.w { "Remote remove: no matching queue entry for ${command.mediaUrl}" }
|
||||
}
|
||||
audioPlayerQueue.removeFromQueueByMediaUrl(command.mediaUrl)
|
||||
}
|
||||
}
|
||||
sendAck(session, envelope.commandId)
|
||||
broadcastState(session)
|
||||
broadcastQueue(session)
|
||||
}
|
||||
|
||||
private suspend fun sendAck(session: WebSocketServerSession, commandId: String) {
|
||||
val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Ack(commandId))
|
||||
val text = json.encodeToString(RemoteControlEvent.Ack.serializer(), RemoteControlEvent.Ack(commandId))
|
||||
session.send(Frame.Text(text))
|
||||
}
|
||||
|
||||
private suspend fun sendError(session: WebSocketServerSession, message: String) {
|
||||
val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Error(message))
|
||||
val text = json.encodeToString(RemoteControlEvent.Error.serializer(), RemoteControlEvent.Error(message))
|
||||
session.send(Frame.Text(text))
|
||||
}
|
||||
|
||||
/**
|
||||
* Player state changes are applied asynchronously (e.g. ExoPlayer listener
|
||||
* callbacks posted to the main looper), so after play/pause we poll until
|
||||
* [playerStateFlow] reflects the expected state before broadcasting it back
|
||||
* to the controller. Otherwise the client would see a stale (inverted) icon.
|
||||
*/
|
||||
private suspend fun waitForPlaybackState(expectPlaying: Boolean, timeoutMs: Long = 1_000) {
|
||||
val timeoutAt = TimeSource.Monotonic.markNow() + timeoutMs.milliseconds
|
||||
while (timeoutAt.hasNotPassedNow()) {
|
||||
if ((audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) == expectPlaying) return
|
||||
delay(25)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a `spotube://` collection source URI (playlist/album/artist top
|
||||
* tracks/saved tracks) and applies the requested action on the remote queue.
|
||||
*/
|
||||
private suspend fun handleCollectionSource(source: String, action: RemoteCollectionAction) {
|
||||
logger.d { "Remote collection $action for source: $source" }
|
||||
when {
|
||||
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
|
||||
val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX)
|
||||
when (action) {
|
||||
RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id)
|
||||
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id)
|
||||
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id)
|
||||
}
|
||||
}
|
||||
|
||||
source.startsWith(COLLECTION_ALBUM_PREFIX) -> {
|
||||
val id = source.removePrefix(COLLECTION_ALBUM_PREFIX)
|
||||
when (action) {
|
||||
RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id)
|
||||
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id)
|
||||
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id)
|
||||
}
|
||||
}
|
||||
|
||||
source.startsWith(COLLECTION_ARTIST_TOP_PREFIX) -> {
|
||||
val id = source.removePrefix(COLLECTION_ARTIST_TOP_PREFIX)
|
||||
when (action) {
|
||||
RemoteCollectionAction.Play -> collectionPlaybackHelper.playArtistTopTracks(id)
|
||||
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(id)
|
||||
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(id)
|
||||
}
|
||||
}
|
||||
|
||||
source == COLLECTION_SAVED_TRACKS -> {
|
||||
when (action) {
|
||||
RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks()
|
||||
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue()
|
||||
RemoteCollectionAction.PlayNext -> {
|
||||
// Saved tracks "play next" is not supported; add to queue instead
|
||||
collectionPlaybackHelper.addSavedTracksToQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> logger.w { "Unknown remote collection source: $source" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun broadcastState(session: WebSocketServerSession) {
|
||||
val current = audioPlayerQueue.currentQueueEntryFlow.value
|
||||
val state = RemoteControlEvent.PlayerState(
|
||||
@ -357,62 +221,10 @@ class RemoteControlHandler(
|
||||
currentTrackAlbum = current?.albumOrNull(),
|
||||
currentTrackCoverUrl = current?.coverUrlOrNull(),
|
||||
)
|
||||
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
|
||||
val text = json.encodeToString(RemoteControlEvent.PlayerState.serializer(), state)
|
||||
session.send(Frame.Text(text))
|
||||
}
|
||||
|
||||
private suspend fun broadcastQueue(session: WebSocketServerSession) {
|
||||
val queue = audioPlayerQueue.queueFlow.value
|
||||
val current = audioPlayerQueue.currentQueueEntryFlow.value
|
||||
val currentIndex = if (current != null) {
|
||||
queue.indexOfFirst { it.matchesCurrent(current) }
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
val event = RemoteControlEvent.QueueUpdated(
|
||||
entries = queue.map { it.toRemoteQueueEntry() },
|
||||
currentIndex = currentIndex,
|
||||
)
|
||||
val text = json.encodeToString(RemoteControlEvent.serializer(), event)
|
||||
session.send(Frame.Text(text))
|
||||
}
|
||||
|
||||
private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean {
|
||||
return when {
|
||||
this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> {
|
||||
this.track.id == current.track.id
|
||||
}
|
||||
|
||||
this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> {
|
||||
this.url == current.url && this.name == current.name
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun QueueEntry.toRemoteQueueEntry(): RemoteQueueEntry = when (this) {
|
||||
is QueueEntry.StreamingTrack -> RemoteQueueEntry(
|
||||
mediaUrl = track.id,
|
||||
trackId = track.id,
|
||||
title = track.title,
|
||||
artists = track.artists.joinToString(", ") { artist -> artist.name },
|
||||
album = track.album?.title,
|
||||
coverUrl = coverUrlOrNull(),
|
||||
durationMs = track.durationMs,
|
||||
)
|
||||
|
||||
is QueueEntry.LocalTrack -> RemoteQueueEntry(
|
||||
mediaUrl = url,
|
||||
trackId = url,
|
||||
title = name,
|
||||
artists = artists.joinToString(", "),
|
||||
album = album,
|
||||
coverUrl = null,
|
||||
durationMs = duration,
|
||||
)
|
||||
}
|
||||
|
||||
private fun QueueEntry.mediaKey(): String = when (this) {
|
||||
is QueueEntry.StreamingTrack -> track.id
|
||||
is QueueEntry.LocalTrack -> url
|
||||
@ -434,8 +246,7 @@ class RemoteControlHandler(
|
||||
}
|
||||
|
||||
private fun QueueEntry.coverUrlOrNull(): String? = when (this) {
|
||||
is QueueEntry.StreamingTrack -> track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
is QueueEntry.StreamingTrack -> track.thumbnails?.firstOrNull()?.url
|
||||
is QueueEntry.LocalTrack -> null
|
||||
}
|
||||
}
|
||||
@ -446,17 +257,6 @@ data class CommandEnvelope(
|
||||
val command: RemoteControlCommand,
|
||||
)
|
||||
|
||||
private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/"
|
||||
private const val COLLECTION_ALBUM_PREFIX = "spotube://album/"
|
||||
private const val COLLECTION_ARTIST_TOP_PREFIX = "spotube://artist/"
|
||||
private const val COLLECTION_SAVED_TRACKS = "spotube://saved_tracks"
|
||||
|
||||
enum class RemoteCollectionAction {
|
||||
Play,
|
||||
AddToQueue,
|
||||
PlayNext,
|
||||
}
|
||||
|
||||
data class ConnectionRequest(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.remote
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@ -63,34 +62,6 @@ sealed class RemoteControlCommand {
|
||||
@SerialName("addToQueue")
|
||||
data class AddToQueue(val source: String) : RemoteControlCommand()
|
||||
|
||||
@Serializable
|
||||
@SerialName("playNext")
|
||||
data class PlayNext(val source: String) : RemoteControlCommand()
|
||||
|
||||
@Serializable
|
||||
@SerialName("playTrack")
|
||||
data class PlayTrack(val track: MetadataTrack) : RemoteControlCommand()
|
||||
|
||||
@Serializable
|
||||
@SerialName("addTrackToQueue")
|
||||
data class AddTrackToQueue(val track: MetadataTrack) : RemoteControlCommand()
|
||||
|
||||
@Serializable
|
||||
@SerialName("playTrackNext")
|
||||
data class PlayTrackNext(val track: MetadataTrack) : RemoteControlCommand()
|
||||
|
||||
@Serializable
|
||||
@SerialName("addTracksToQueue")
|
||||
data class AddTracksToQueue(val tracks: List<MetadataTrack>) : RemoteControlCommand()
|
||||
|
||||
@Serializable
|
||||
@SerialName("playTracksNext")
|
||||
data class PlayTracksNext(val tracks: List<MetadataTrack>) : RemoteControlCommand()
|
||||
|
||||
@Serializable
|
||||
@SerialName("playIndex")
|
||||
data class PlayIndex(val index: Int) : RemoteControlCommand()
|
||||
|
||||
@Serializable
|
||||
@SerialName("removeFromQueue")
|
||||
data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand()
|
||||
|
||||
@ -18,307 +18,100 @@
|
||||
package dev.krtirtho.spotube.core.remote
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
enum class PlaybackDestinationAction {
|
||||
Play,
|
||||
AddToQueue,
|
||||
PlayNext,
|
||||
}
|
||||
|
||||
enum class RemoteCollectionType {
|
||||
Playlist,
|
||||
Album,
|
||||
ArtistTopTracks,
|
||||
SavedTracks,
|
||||
}
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* A playback request awaiting a destination choice (local device vs a connected
|
||||
* remote device). [title] is the content label shown in the picker dialog.
|
||||
* Manages the play destination picker state and remote playback commands.
|
||||
* Injected into ViewModels to handle playback actions when a remote device is connected.
|
||||
*/
|
||||
sealed interface PlaybackDestinationRequest {
|
||||
val title: String
|
||||
val action: PlaybackDestinationAction
|
||||
|
||||
data class Collection(
|
||||
override val title: String,
|
||||
override val action: PlaybackDestinationAction,
|
||||
val type: RemoteCollectionType,
|
||||
val id: String,
|
||||
val startTrack: MetadataTrack? = null,
|
||||
) : PlaybackDestinationRequest
|
||||
|
||||
data class Track(
|
||||
override val title: String,
|
||||
override val action: PlaybackDestinationAction,
|
||||
val track: MetadataTrack,
|
||||
) : PlaybackDestinationRequest
|
||||
|
||||
data class Tracks(
|
||||
override val title: String,
|
||||
override val action: PlaybackDestinationAction,
|
||||
val tracks: List<MetadataTrack>,
|
||||
) : PlaybackDestinationRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes playback actions (play / add to queue / play next) to either the local
|
||||
* device or a connected remote device. When a remote device is connected the
|
||||
* user is shown a destination picker; otherwise the action runs locally.
|
||||
*/
|
||||
class RemotePlaybackController(
|
||||
private val remoteControlClient: RemoteControlClient,
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) : KoinComponent {
|
||||
class RemotePlaybackController : KoinComponent {
|
||||
private val logger = Logger.withTag("RemotePlaybackController")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val remoteControlClient: RemoteControlClient by inject()
|
||||
|
||||
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null)
|
||||
val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _pendingRequest.asStateFlow()
|
||||
private val _showPicker = MutableStateFlow(false)
|
||||
val showPicker: StateFlow<Boolean> = _showPicker.asStateFlow()
|
||||
|
||||
private var pendingAction: (() -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Checks if a remote device is connected.
|
||||
*/
|
||||
fun isRemoteConnected(): Boolean {
|
||||
return remoteControlClient.connectionState.value is ConnectionState.Connected
|
||||
}
|
||||
|
||||
// ---------- Collection actions ----------
|
||||
|
||||
fun requestCollectionPlay(
|
||||
type: RemoteCollectionType,
|
||||
id: String,
|
||||
title: String,
|
||||
startTrack: MetadataTrack? = null,
|
||||
) {
|
||||
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack))
|
||||
}
|
||||
|
||||
fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) {
|
||||
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.AddToQueue, type, id))
|
||||
}
|
||||
|
||||
fun requestCollectionPlayNext(type: RemoteCollectionType, id: String, title: String) {
|
||||
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.PlayNext, type, id))
|
||||
}
|
||||
|
||||
// ---------- Single track actions ----------
|
||||
|
||||
fun requestTrackAddToQueue(track: MetadataTrack) {
|
||||
request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.AddToQueue, track))
|
||||
}
|
||||
|
||||
fun requestTrackPlayNext(track: MetadataTrack) {
|
||||
request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.PlayNext, track))
|
||||
}
|
||||
|
||||
// ---------- Bulk track actions ----------
|
||||
|
||||
fun requestTracksAddToQueue(tracks: List<MetadataTrack>, title: String) {
|
||||
if (tracks.isEmpty()) return
|
||||
request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.AddToQueue, tracks))
|
||||
}
|
||||
|
||||
fun requestTracksPlayNext(tracks: List<MetadataTrack>, title: String) {
|
||||
if (tracks.isEmpty()) return
|
||||
request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.PlayNext, tracks))
|
||||
}
|
||||
|
||||
// ---------- Picker resolution ----------
|
||||
|
||||
fun playLocally() {
|
||||
val request = _pendingRequest.value ?: return
|
||||
_pendingRequest.value = null
|
||||
executeLocally(request)
|
||||
}
|
||||
|
||||
fun playOnRemote() {
|
||||
val request = _pendingRequest.value ?: return
|
||||
_pendingRequest.value = null
|
||||
executeOnRemote(request)
|
||||
}
|
||||
|
||||
fun dismissPicker() {
|
||||
_pendingRequest.value = null
|
||||
}
|
||||
|
||||
// ---------- Internals ----------
|
||||
|
||||
private fun request(request: PlaybackDestinationRequest) {
|
||||
/**
|
||||
* Wraps a playback action. If a remote device is connected, shows the picker.
|
||||
* Otherwise, executes the action immediately.
|
||||
*
|
||||
* @param action The action to execute if playing locally
|
||||
*/
|
||||
fun wrapPlaybackAction(action: () -> Unit) {
|
||||
if (isRemoteConnected()) {
|
||||
_pendingRequest.value = request
|
||||
pendingAction = action
|
||||
_showPicker.value = true
|
||||
} else {
|
||||
executeLocally(request)
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeLocally(request: PlaybackDestinationRequest) {
|
||||
scope.launch {
|
||||
when (request) {
|
||||
is PlaybackDestinationRequest.Collection -> {
|
||||
val startTrack = request.startTrack
|
||||
when (request.type) {
|
||||
RemoteCollectionType.Playlist -> when (request.action) {
|
||||
PlaybackDestinationAction.Play -> {
|
||||
if (startTrack != null) {
|
||||
collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack)
|
||||
} else {
|
||||
collectionPlaybackHelper.playPlaylist(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id)
|
||||
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id)
|
||||
}
|
||||
|
||||
RemoteCollectionType.Album -> when (request.action) {
|
||||
PlaybackDestinationAction.Play -> {
|
||||
if (startTrack != null) {
|
||||
collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack)
|
||||
} else {
|
||||
collectionPlaybackHelper.playAlbum(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id)
|
||||
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id)
|
||||
}
|
||||
|
||||
RemoteCollectionType.ArtistTopTracks -> when (request.action) {
|
||||
PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id)
|
||||
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id)
|
||||
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id)
|
||||
}
|
||||
|
||||
RemoteCollectionType.SavedTracks -> when (request.action) {
|
||||
PlaybackDestinationAction.Play -> {
|
||||
if (startTrack != null) {
|
||||
collectionPlaybackHelper.playSavedTracksFromTrack(startTrack)
|
||||
} else {
|
||||
collectionPlaybackHelper.playSavedTracks()
|
||||
}
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue()
|
||||
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is PlaybackDestinationRequest.Track -> {
|
||||
val entry = QueueEntry.StreamingTrack(track = request.track, url = "")
|
||||
when (request.action) {
|
||||
PlaybackDestinationAction.Play -> {
|
||||
audioPlayerQueue.load(
|
||||
entries = listOf(entry),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.AddToQueue -> {
|
||||
audioPlayerQueue.addToQueue(entry)
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.PlayNext -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
queue.find { candidate ->
|
||||
(candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true
|
||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is PlaybackDestinationRequest.Tracks -> {
|
||||
val entries = request.tracks
|
||||
.filter { track -> !isTrackBlacklisted(track) }
|
||||
.map { track -> QueueEntry.StreamingTrack(track = track, url = "") }
|
||||
when (request.action) {
|
||||
PlaybackDestinationAction.Play -> {
|
||||
audioPlayerQueue.load(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.AddToQueue -> {
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.PlayNext -> {
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Called when the user chooses to play locally.
|
||||
*/
|
||||
fun playLocally() {
|
||||
_showPicker.value = false
|
||||
pendingAction?.invoke()
|
||||
pendingAction = null
|
||||
}
|
||||
|
||||
private fun executeOnRemote(request: PlaybackDestinationRequest) {
|
||||
scope.launch {
|
||||
/**
|
||||
* Called when the user chooses to play on the remote device.
|
||||
* Sends a play command to the remote device.
|
||||
*
|
||||
* @param source The source identifier (e.g., playlist ID, album ID, track ID)
|
||||
*/
|
||||
fun playOnRemote(source: String) {
|
||||
_showPicker.value = false
|
||||
pendingAction = null
|
||||
|
||||
CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
|
||||
try {
|
||||
val command = when (request) {
|
||||
is PlaybackDestinationRequest.Collection -> {
|
||||
val source = when (request.type) {
|
||||
RemoteCollectionType.Playlist -> "spotube://playlist/${request.id}"
|
||||
RemoteCollectionType.Album -> "spotube://album/${request.id}"
|
||||
RemoteCollectionType.ArtistTopTracks -> "spotube://artist/${request.id}"
|
||||
RemoteCollectionType.SavedTracks -> "spotube://saved_tracks"
|
||||
}
|
||||
when (request.action) {
|
||||
PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source)
|
||||
PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source)
|
||||
PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source)
|
||||
}
|
||||
}
|
||||
|
||||
is PlaybackDestinationRequest.Track -> when (request.action) {
|
||||
PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTrack(request.track)
|
||||
PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTrackToQueue(request.track)
|
||||
PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTrackNext(request.track)
|
||||
}
|
||||
|
||||
is PlaybackDestinationRequest.Tracks -> when (request.action) {
|
||||
PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTracksNext(request.tracks)
|
||||
PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTracksToQueue(request.tracks)
|
||||
PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTracksNext(request.tracks)
|
||||
}
|
||||
}
|
||||
remoteControlClient.sendCommand(command)
|
||||
logger.i { "Sent remote ${request.action} for ${request.title}" }
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.Play(source))
|
||||
logger.i { "Sent play command for source: $source" }
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to send remote playback command" }
|
||||
logger.e(e) { "Failed to send play command" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||
val trackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val artistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
return track.id in trackIds || track.artists.any { it.id in artistIds }
|
||||
/**
|
||||
* Called when the user dismisses the picker.
|
||||
*/
|
||||
fun dismissPicker() {
|
||||
_showPicker.value = false
|
||||
pendingAction = null
|
||||
}
|
||||
|
||||
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
|
||||
if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id
|
||||
return title == other.title &&
|
||||
durationMs == other.durationMs &&
|
||||
album?.id == other.album?.id &&
|
||||
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
|
||||
/**
|
||||
* Sends an add-to-queue command to the remote device.
|
||||
*
|
||||
* @param source The source identifier (e.g., playlist ID, album ID, track ID)
|
||||
*/
|
||||
fun addToQueueOnRemote(source: String) {
|
||||
CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
|
||||
try {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.AddToQueue(source))
|
||||
logger.i { "Sent add-to-queue command for source: $source" }
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to send add-to-queue command" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ package dev.krtirtho.spotube.core.ui.component
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@ -28,9 +29,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
@ -39,9 +39,9 @@ fun AlbumCard(
|
||||
modifier: Modifier = Modifier,
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject()
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
PlayableCard(
|
||||
@ -54,18 +54,10 @@ fun AlbumCard(
|
||||
},
|
||||
onPlay = {
|
||||
if (currentCollectionEntry?.id == album.id) return@PlayableCard
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
RemoteCollectionType.Album,
|
||||
album.id,
|
||||
album.title,
|
||||
)
|
||||
scope.launch { playbackHelper.playAlbum(album.id) }
|
||||
},
|
||||
onAddToQueue = {
|
||||
remotePlaybackController.requestCollectionAddToQueue(
|
||||
RemoteCollectionType.Album,
|
||||
album.id,
|
||||
album.title,
|
||||
)
|
||||
scope.launch { playbackHelper.addAlbumToQueue(album.id) }
|
||||
},
|
||||
modifier = modifier.width(160.dp),
|
||||
sharedElementKey = "album_art_${album.id}",
|
||||
|
||||
@ -27,8 +27,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
@ -39,8 +37,7 @@ fun PlaylistCard(
|
||||
modifier: Modifier = Modifier,
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject()
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
@ -55,18 +52,10 @@ fun PlaylistCard(
|
||||
},
|
||||
onPlay = {
|
||||
if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
RemoteCollectionType.Playlist,
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
)
|
||||
scope.launch { playbackHelper.playPlaylist(playlist.id) }
|
||||
},
|
||||
onAddToQueue = {
|
||||
remotePlaybackController.requestCollectionAddToQueue(
|
||||
RemoteCollectionType.Playlist,
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
)
|
||||
scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) }
|
||||
},
|
||||
modifier = modifier,
|
||||
sharedElementKey = "playlist_art_${playlist.id}",
|
||||
|
||||
@ -26,8 +26,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||
@ -99,7 +97,6 @@ class AlbumViewModel(
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
private val shareService: ShareService,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val remotePlaybackController: RemotePlaybackController,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<AlbumViewModel>()
|
||||
|
||||
@ -220,28 +217,15 @@ class AlbumViewModel(
|
||||
}
|
||||
|
||||
fun playAlbum() {
|
||||
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title)
|
||||
viewModelScope.launch { playbackHelper.playAlbum(albumId) }
|
||||
}
|
||||
|
||||
fun addAlbumToQueue() {
|
||||
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title)
|
||||
}
|
||||
|
||||
fun playAlbumNext() {
|
||||
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||
remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Album, albumId, title)
|
||||
viewModelScope.launch { playbackHelper.addAlbumToQueue(albumId) }
|
||||
}
|
||||
|
||||
fun playAlbumFromTrack(track: MetadataTrack) {
|
||||
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
type = RemoteCollectionType.Album,
|
||||
id = albumId,
|
||||
title = title,
|
||||
startTrack = track,
|
||||
)
|
||||
viewModelScope.launch { playbackHelper.playAlbumFromTrack(albumId, track) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
@ -257,20 +241,14 @@ class AlbumViewModel(
|
||||
is TrackOptionsAction.StartRadio -> {}
|
||||
is TrackOptionsAction.PlayNext -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
val existing = queue.find { entry ->
|
||||
queue.find { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (existing != null) {
|
||||
// Already in the local queue: move it to the next position
|
||||
audioPlayerQueue.removeFromQueue(existing)
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
} else {
|
||||
remotePlaybackController.requestTrackPlayNext(track)
|
||||
}
|
||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
@ -334,13 +312,33 @@ class AlbumViewModel(
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||
viewModelScope.launch {
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||
remotePlaybackController.requestTracksPlayNext(tracks, title)
|
||||
viewModelScope.launch {
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||
|
||||
@ -27,8 +27,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||
@ -75,7 +73,6 @@ class ArtistViewModel(
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
private val shareService: ShareService,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val remotePlaybackController: RemotePlaybackController,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<ArtistViewModel>()
|
||||
|
||||
@ -250,40 +247,80 @@ class ArtistViewModel(
|
||||
}
|
||||
|
||||
fun addTopTracksToQueue() {
|
||||
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||
remotePlaybackController.requestCollectionAddToQueue(
|
||||
RemoteCollectionType.ArtistTopTracks,
|
||||
artistId,
|
||||
artistName,
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val entries = resolveTopTrackEntries()
|
||||
if (entries.isEmpty()) return@launch
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun playTopTracks() {
|
||||
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
RemoteCollectionType.ArtistTopTracks,
|
||||
artistId,
|
||||
artistName,
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val entries = resolveTopTrackEntries()
|
||||
if (entries.isEmpty()) return@launch
|
||||
audioPlayerQueue.load(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun playTopTracksFromTrack(track: MetadataTrack) {
|
||||
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
type = RemoteCollectionType.ArtistTopTracks,
|
||||
id = artistId,
|
||||
title = artistName,
|
||||
startTrack = track,
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
val queueIndex = queue.indexOfFirst { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (queueIndex >= 0) {
|
||||
audioPlayerQueue.jumpTo(queueIndex)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val entries = resolveTopTrackEntries()
|
||||
if (entries.isEmpty()) return@launch
|
||||
|
||||
val startPosition = entries.indexOfFirst { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}.coerceAtLeast(0)
|
||||
|
||||
audioPlayerQueue.load(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = startPosition,
|
||||
collectionEntry = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
|
||||
viewModelScope.launch {
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||
remotePlaybackController.requestTracksPlayNext(tracks, artistName)
|
||||
viewModelScope.launch {
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||
@ -292,19 +329,13 @@ class ArtistViewModel(
|
||||
is TrackOptionsAction.StartRadio -> {}
|
||||
is TrackOptionsAction.PlayNext -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
val existing = queue.find { entry ->
|
||||
queue.find { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (existing != null) {
|
||||
// Already in the local queue: move it to the next position
|
||||
audioPlayerQueue.removeFromQueue(existing)
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
} else {
|
||||
remotePlaybackController.requestTrackPlayNext(track)
|
||||
}
|
||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
}
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
||||
}
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
|
||||
@ -72,8 +72,7 @@ fun DevicesScreen(
|
||||
viewModel.startDiscovery()
|
||||
onDispose {
|
||||
viewModel.stopDiscovery()
|
||||
// Don't disconnect here - the connection should persist when navigating to RemoteControlScreen
|
||||
// The RemoteControlViewModel will manage the connection lifecycle
|
||||
viewModel.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,10 +17,8 @@
|
||||
|
||||
package dev.krtirtho.spotube.modules.devices
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@ -28,49 +26,38 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.core.remote.ConnectionState
|
||||
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
|
||||
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.ui.base.ListRowTile
|
||||
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
/**
|
||||
* Globally hosted dialog shown when a remote device is connected and the user
|
||||
* tries to play / add to queue / play next. Lets the user choose between the
|
||||
* local device and the connected remote device(s).
|
||||
* Dialog shown when a remote device is connected and the user tries to play/add to queue.
|
||||
* Allows the user to choose between playing on the local device or the remote device.
|
||||
*/
|
||||
@Composable
|
||||
fun PlayDestinationPickerHost() {
|
||||
val controller = koinInject<RemotePlaybackController>()
|
||||
fun PlayDestinationPicker(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onPlayLocally: () -> Unit,
|
||||
onPlayOnRemote: () -> Unit,
|
||||
) {
|
||||
val remoteControlClient = koinInject<RemoteControlClient>()
|
||||
val request by controller.pendingRequest.collectAsStateWithLifecycle()
|
||||
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
|
||||
|
||||
val pendingRequest = request ?: return
|
||||
if (!visible) return
|
||||
|
||||
val remoteDeviceName = when (val state = connectionState) {
|
||||
is ConnectionState.Connected -> "Remote Device (${state.host})"
|
||||
else -> "Remote Device"
|
||||
}
|
||||
|
||||
val actionLabel = when (pendingRequest.action) {
|
||||
PlaybackDestinationAction.Play -> "Play"
|
||||
PlaybackDestinationAction.AddToQueue -> "Add to queue"
|
||||
PlaybackDestinationAction.PlayNext -> "Play next"
|
||||
}
|
||||
|
||||
ThemedDialog(
|
||||
onDismissRequest = controller::dismissPicker,
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "Where to $actionLabel?",
|
||||
text = "Play Where?",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
@ -78,69 +65,24 @@ fun PlayDestinationPickerHost() {
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "$actionLabel \"${pendingRequest.title}\" on:",
|
||||
text = "Choose where to play this content:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
ListRowTile(
|
||||
onClick = controller::playLocally,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxCd,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "This Device",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
subtitle = {
|
||||
Text(
|
||||
text = "$actionLabel here",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
ListRowTile(
|
||||
onClick = controller::playOnRemote,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMirroringScreen,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = remoteDeviceName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
subtitle = {
|
||||
Text(
|
||||
text = "$actionLabel on the connected device",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = controller::dismissPicker) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
TextButton(onClick = onPlayLocally) {
|
||||
Text("This Device")
|
||||
}
|
||||
TextButton(onClick = onPlayOnRemote) {
|
||||
Text(remoteDeviceName)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -18,30 +18,21 @@
|
||||
package dev.krtirtho.spotube.modules.devices
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -49,12 +40,11 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@ -63,22 +53,12 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.spotube.core.remote.ConnectionState
|
||||
import dev.krtirtho.spotube.core.remote.RemoteQueueEntry
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.ListRowTile
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.Slider
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||
@ -97,134 +77,73 @@ fun RemoteControlScreen(
|
||||
val viewModel = koinViewModel<RemoteControlViewModel>()
|
||||
val playerState by viewModel.playerState.collectAsStateWithLifecycle()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val queueState by viewModel.queueState.collectAsStateWithLifecycle()
|
||||
val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle()
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
|
||||
// Center the mobile-inspired layout and limit its width so it doesn't
|
||||
// stretch awkwardly on large screens.
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(),
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
title = { Text("Remote Control") },
|
||||
backButton = true,
|
||||
actions = {
|
||||
GhostIconButton(
|
||||
onClick = viewModel::toggleQueueVisibility,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMusicFilter,
|
||||
contentDescription = "Queue",
|
||||
tint = if (isQueueVisible) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
title = { Text("Remote Control") },
|
||||
backButton = true,
|
||||
actions = {
|
||||
GhostIconButton(
|
||||
onClick = {
|
||||
viewModel.disconnect()
|
||||
onDisconnect()
|
||||
}
|
||||
GhostIconButton(
|
||||
onClick = {
|
||||
viewModel.disconnect()
|
||||
onDisconnect()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxCloseSquare,
|
||||
contentDescription = "Disconnect",
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
when (connectionState) {
|
||||
is ConnectionState.Connected -> {
|
||||
RemoteControlContent(
|
||||
playerState = playerState,
|
||||
onTogglePlayPause = viewModel::togglePlayPause,
|
||||
onSkipNext = viewModel::skipNext,
|
||||
onSkipPrevious = viewModel::skipPrevious,
|
||||
onSeek = viewModel::seek,
|
||||
onSetVolume = viewModel::setVolume,
|
||||
onToggleShuffle = viewModel::toggleShuffle,
|
||||
onCycleLoopMode = viewModel::cycleLoopMode,
|
||||
modifier = Modifier.padding(padding).padding(bottom = shellBottomInset)
|
||||
)
|
||||
}
|
||||
is ConnectionState.Connecting -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(bottom = shellBottomInset),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Connecting...")
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxCloseSquare,
|
||||
contentDescription = "Disconnect",
|
||||
)
|
||||
}
|
||||
}
|
||||
is ConnectionState.Disconnected -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(bottom = shellBottomInset),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Disconnected")
|
||||
}
|
||||
}
|
||||
is ConnectionState.Error -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(bottom = shellBottomInset),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Connection error: ${(connectionState as ConnectionState.Error).message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Click-outside scrim for the sliding queue sheet on large screens.
|
||||
// (The ModalBottomSheet variant has its own built-in scrim.)
|
||||
if (isQueueVisible) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { viewModel.toggleQueueVisibility() },
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the sliding sheet above the AppLargePlayer on large screens.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = shellBottomInset),
|
||||
) {
|
||||
QueueSheet(
|
||||
isVisible = isQueueVisible,
|
||||
onDismiss = { viewModel.toggleQueueVisibility() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
RemoteQueueSection(
|
||||
queueState = queueState,
|
||||
onPlayQueueItem = viewModel::playQueueItem,
|
||||
onRemoveQueueItem = viewModel::removeQueueItem,
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
when (connectionState) {
|
||||
is ConnectionState.Connected -> {
|
||||
RemoteControlContent(
|
||||
playerState = playerState,
|
||||
onTogglePlayPause = viewModel::togglePlayPause,
|
||||
onSkipNext = viewModel::skipNext,
|
||||
onSkipPrevious = viewModel::skipPrevious,
|
||||
onSeek = viewModel::seek,
|
||||
onSetVolume = viewModel::setVolume,
|
||||
onToggleShuffle = viewModel::toggleShuffle,
|
||||
onCycleLoopMode = viewModel::cycleLoopMode,
|
||||
modifier = Modifier.padding(padding)
|
||||
)
|
||||
}
|
||||
is ConnectionState.Connecting -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Connecting...")
|
||||
}
|
||||
}
|
||||
is ConnectionState.Disconnected -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Disconnected")
|
||||
}
|
||||
}
|
||||
is ConnectionState.Error -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Connection error: ${(connectionState as ConnectionState.Error).message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,375 +159,212 @@ private fun RemoteControlContent(
|
||||
onCycleLoopMode: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box (modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Album art
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
) {
|
||||
AsyncImage(
|
||||
model = playerState.currentTrackCoverUrl,
|
||||
contentDescription = "Album cover",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Track info
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp)
|
||||
.widthIn(max = 480.dp)
|
||||
.align(Alignment.TopCenter),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
text = playerState.currentTrackTitle ?: "Unknown Track",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
// Album art
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
if (playerState.currentTrackCoverUrl != null) {
|
||||
AsyncImage(
|
||||
model = playerState.currentTrackCoverUrl,
|
||||
contentDescription = "Album cover",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMusicFilter,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
text = playerState.currentTrackArtists ?: "Unknown Artist",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
// Track info
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (playerState.currentTrackAlbum != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = playerState.currentTrackTitle ?: "Unknown Track",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = playerState.currentTrackArtists ?: "Unknown Artist",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
text = playerState.currentTrackAlbum!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (playerState.currentTrackAlbum != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = playerState.currentTrackAlbum!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Seek bar
|
||||
Column(
|
||||
// Seek bar
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Slider(
|
||||
value = playerState.positionMs.toFloat(),
|
||||
onValueChange = { onSeek(it.toLong()) },
|
||||
valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Slider(
|
||||
value = playerState.positionMs.toFloat(),
|
||||
onValueChange = { onSeek(it.toLong()) },
|
||||
valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Text(
|
||||
text = formatDuration(playerState.positionMs),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = formatDuration(playerState.durationMs),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = formatDuration(playerState.positionMs),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = formatDuration(playerState.durationMs),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Playback controls
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Shuffle
|
||||
IconButton(
|
||||
onClick = onToggleShuffle,
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxShuffle,
|
||||
contentDescription = "Shuffle",
|
||||
tint = if (playerState.shuffleEnabled) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Skip previous
|
||||
IconButton(
|
||||
onClick = onSkipPrevious,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxPrevious,
|
||||
contentDescription = "Previous",
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Play/Pause
|
||||
val baseTheme = LocalBaseUITheme.current
|
||||
val circlePrimaryIconTheme = remember(baseTheme) {
|
||||
baseTheme.iconButtons.primary.copy(
|
||||
shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape)
|
||||
)
|
||||
}
|
||||
PrimaryIconButton(
|
||||
onClick = onTogglePlayPause,
|
||||
modifier = Modifier.size(72.dp),
|
||||
theme = circlePrimaryIconTheme,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (playerState.isPlaying) {
|
||||
Iconsax.IconsaxPause
|
||||
} else {
|
||||
Iconsax.IconsaxPlay
|
||||
},
|
||||
contentDescription = if (playerState.isPlaying) "Pause" else "Play",
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Skip next
|
||||
IconButton(
|
||||
onClick = onSkipNext,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxNext,
|
||||
contentDescription = "Next",
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Loop mode
|
||||
IconButton(
|
||||
onClick = onCycleLoopMode,
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxRepeateMusic,
|
||||
contentDescription = "Loop mode",
|
||||
tint = if (playerState.loopMode != "none") {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Volume control
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
// Playback controls
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Shuffle
|
||||
IconButton(
|
||||
onClick = onToggleShuffle,
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxVolumeHigh,
|
||||
contentDescription = "Volume",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = playerState.volume,
|
||||
onValueChange = onSetVolume,
|
||||
valueRange = 0f..1f,
|
||||
modifier = Modifier.weight(1f),
|
||||
imageVector = Iconsax.IconsaxShuffle,
|
||||
contentDescription = "Shuffle",
|
||||
tint = if (playerState.shuffleEnabled) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteQueueSection(
|
||||
queueState: RemoteQueueState,
|
||||
onPlayQueueItem: (Int) -> Unit,
|
||||
onRemoveQueueItem: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Queue",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
|
||||
if (queueState.entries.isEmpty()) {
|
||||
Text(
|
||||
text = "No queue entries",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
// Skip previous
|
||||
IconButton(
|
||||
onClick = onSkipPrevious,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = queueState.entries,
|
||||
key = { index, entry -> "${entry.mediaUrl}@$index" },
|
||||
) { index, entry ->
|
||||
RemoteQueueItemRow(
|
||||
entry = entry,
|
||||
index = index,
|
||||
isCurrent = index == queueState.currentIndex,
|
||||
onPlayClick = { onPlayQueueItem(index) },
|
||||
onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) },
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxPrevious,
|
||||
contentDescription = "Previous",
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteQueueItemRow(
|
||||
entry: RemoteQueueEntry,
|
||||
index: Int,
|
||||
isCurrent: Boolean,
|
||||
onPlayClick: () -> Unit,
|
||||
onRemoveClick: () -> Unit,
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
ListRowTile(
|
||||
onClick = onPlayClick,
|
||||
selected = isCurrent,
|
||||
modifier = Modifier,
|
||||
leading = {
|
||||
Box(
|
||||
// Play/Pause
|
||||
IconButton(
|
||||
onClick = onTogglePlayPause,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
.size(72.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = CircleShape,
|
||||
),
|
||||
) {
|
||||
if (entry.coverUrl != null) {
|
||||
AsyncImage(
|
||||
model = entry.coverUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "${index + 1}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (playerState.isPlaying) {
|
||||
Iconsax.IconsaxPause
|
||||
} else {
|
||||
Iconsax.IconsaxPlay
|
||||
},
|
||||
contentDescription = if (playerState.isPlaying) "Pause" else "Play",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (isCurrent) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
},
|
||||
subtitle = {
|
||||
Text(
|
||||
text = entry.artists,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Text(
|
||||
text = formatDuration(entry.durationMs),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Box {
|
||||
GhostIconButton(
|
||||
onClick = { showMenu = true },
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "More options",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove from queue") },
|
||||
onClick = {
|
||||
onRemoveClick()
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null)
|
||||
},
|
||||
)
|
||||
}
|
||||
// Skip next
|
||||
IconButton(
|
||||
onClick = onSkipNext,
|
||||
modifier = Modifier.size(56.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxNext,
|
||||
contentDescription = "Next",
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Loop mode
|
||||
IconButton(
|
||||
onClick = onCycleLoopMode,
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxRepeateMusic,
|
||||
contentDescription = "Loop mode",
|
||||
tint = if (playerState.loopMode != "none") {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Volume control
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxVolumeHigh,
|
||||
contentDescription = "Volume",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = playerState.volume,
|
||||
onValueChange = onSetVolume,
|
||||
valueRange = 0f..1f,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(ms: Long): String {
|
||||
|
||||
@ -24,7 +24,7 @@ import dev.krtirtho.spotube.core.remote.ConnectionState
|
||||
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
||||
import dev.krtirtho.spotube.core.remote.RemoteControlCommand
|
||||
import dev.krtirtho.spotube.core.remote.RemoteControlEvent
|
||||
import dev.krtirtho.spotube.core.remote.RemoteQueueEntry
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@ -47,11 +47,6 @@ data class RemotePlayerState(
|
||||
val currentTrackCoverUrl: String? = null,
|
||||
)
|
||||
|
||||
data class RemoteQueueState(
|
||||
val entries: List<RemoteQueueEntry> = emptyList(),
|
||||
val currentIndex: Int = -1,
|
||||
)
|
||||
|
||||
class RemoteControlViewModel : ViewModel(), KoinComponent {
|
||||
private val logger = Logger.withTag("RemoteControlViewModel")
|
||||
private val remoteControlClient: RemoteControlClient by inject()
|
||||
@ -62,11 +57,7 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _queueState = MutableStateFlow(RemoteQueueState())
|
||||
val queueState: StateFlow<RemoteQueueState> = _queueState.asStateFlow()
|
||||
|
||||
private val _isQueueVisible = MutableStateFlow(false)
|
||||
val isQueueVisible: StateFlow<Boolean> = _isQueueVisible.asStateFlow()
|
||||
private var stateUpdateJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
@ -76,47 +67,52 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.latestPlayerState.collect { event ->
|
||||
if (event != null) {
|
||||
handlePlayerState(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.latestQueue.collect { event ->
|
||||
if (event != null) {
|
||||
handleQueueUpdated(event)
|
||||
}
|
||||
remoteControlClient.stateUpdates.collect { event ->
|
||||
handleStateUpdate(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlayerState(event: RemoteControlEvent.PlayerState) {
|
||||
_playerState.update {
|
||||
it.copy(
|
||||
isPlaying = event.isPlaying,
|
||||
positionMs = event.positionMs,
|
||||
durationMs = event.durationMs,
|
||||
volume = event.volume,
|
||||
shuffleEnabled = event.shuffleEnabled,
|
||||
loopMode = event.loopMode,
|
||||
currentTrackId = event.currentTrackId,
|
||||
currentTrackTitle = event.currentTrackTitle,
|
||||
currentTrackArtists = event.currentTrackArtists,
|
||||
currentTrackAlbum = event.currentTrackAlbum,
|
||||
currentTrackCoverUrl = event.currentTrackCoverUrl,
|
||||
)
|
||||
private fun handleStateUpdate(event: RemoteControlEvent) {
|
||||
when (event) {
|
||||
is RemoteControlEvent.Connected -> {
|
||||
// Connection already handled in RemoteControlClient
|
||||
logger.d { "Connection confirmed" }
|
||||
}
|
||||
is RemoteControlEvent.WaitingForPermission -> {
|
||||
// Waiting for permission - no action needed
|
||||
logger.d { "Waiting for permission: ${event.message}" }
|
||||
}
|
||||
is RemoteControlEvent.PlayerState -> {
|
||||
_playerState.update {
|
||||
it.copy(
|
||||
isPlaying = event.isPlaying,
|
||||
positionMs = event.positionMs,
|
||||
durationMs = event.durationMs,
|
||||
volume = event.volume,
|
||||
shuffleEnabled = event.shuffleEnabled,
|
||||
loopMode = event.loopMode,
|
||||
currentTrackId = event.currentTrackId,
|
||||
currentTrackTitle = event.currentTrackTitle,
|
||||
currentTrackArtists = event.currentTrackArtists,
|
||||
currentTrackAlbum = event.currentTrackAlbum,
|
||||
currentTrackCoverUrl = event.currentTrackCoverUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
is RemoteControlEvent.QueueUpdated -> {
|
||||
// TODO: Handle queue updates if needed
|
||||
logger.d { "Queue updated: ${event.entries.size} entries" }
|
||||
}
|
||||
is RemoteControlEvent.Ack -> {
|
||||
logger.d { "Command acknowledged: ${event.commandId}" }
|
||||
}
|
||||
is RemoteControlEvent.Error -> {
|
||||
logger.e { "Remote error: ${event.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) {
|
||||
_queueState.value = RemoteQueueState(
|
||||
entries = event.entries,
|
||||
currentIndex = event.currentIndex,
|
||||
)
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause)
|
||||
@ -166,23 +162,6 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
|
||||
}
|
||||
}
|
||||
|
||||
fun playQueueItem(index: Int) {
|
||||
if (index < 0) return
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeQueueItem(mediaUrl: String) {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl))
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleQueueVisibility() {
|
||||
_isQueueVisible.update { !it }
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.disconnect()
|
||||
|
||||
@ -38,6 +38,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||
import dev.krtirtho.spotube.modules.devices.PlayDestinationPicker
|
||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
|
||||
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
|
||||
@ -59,6 +60,7 @@ fun PlaylistScreen(
|
||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||
val showPlayDestinationPicker by viewModel.showPlayDestinationPicker.collectAsStateWithLifecycle()
|
||||
var showEditPlaylist by remember { mutableStateOf(false) }
|
||||
var showAddTracksDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@ -169,6 +171,13 @@ fun PlaylistScreen(
|
||||
viewModel.refresh()
|
||||
},
|
||||
)
|
||||
|
||||
PlayDestinationPicker(
|
||||
visible = showPlayDestinationPicker,
|
||||
onDismiss = viewModel::dismissPlayPicker,
|
||||
onPlayLocally = viewModel::playLocally,
|
||||
onPlayOnRemote = viewModel::playOnRemote,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -26,7 +26,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
@ -118,6 +117,8 @@ class PlaylistViewModel(
|
||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
||||
|
||||
val showPlayDestinationPicker = remotePlaybackController.showPicker
|
||||
|
||||
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
|
||||
private val _showAddToPlaylistPicker = MutableStateFlow(false)
|
||||
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
|
||||
@ -226,28 +227,35 @@ class PlaylistViewModel(
|
||||
}
|
||||
|
||||
fun playPlaylist() {
|
||||
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title)
|
||||
remotePlaybackController.wrapPlaybackAction {
|
||||
viewModelScope.launch { playbackHelper.playPlaylist(playlistId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun addPlaylistToQueue() {
|
||||
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title)
|
||||
}
|
||||
|
||||
fun playPlaylistNext() {
|
||||
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||
remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Playlist, playlistId, title)
|
||||
if (remotePlaybackController.isRemoteConnected()) {
|
||||
remotePlaybackController.addToQueueOnRemote(playlistId)
|
||||
} else {
|
||||
viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun playPlaylistFromTrack(track: MetadataTrack) {
|
||||
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
type = RemoteCollectionType.Playlist,
|
||||
id = playlistId,
|
||||
title = title,
|
||||
startTrack = track,
|
||||
)
|
||||
remotePlaybackController.wrapPlaybackAction {
|
||||
viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) }
|
||||
}
|
||||
}
|
||||
|
||||
fun playLocally() {
|
||||
remotePlaybackController.playLocally()
|
||||
}
|
||||
|
||||
fun playOnRemote() {
|
||||
remotePlaybackController.playOnRemote(playlistId)
|
||||
}
|
||||
|
||||
fun dismissPlayPicker() {
|
||||
remotePlaybackController.dismissPicker()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
@ -290,20 +298,14 @@ class PlaylistViewModel(
|
||||
is TrackOptionsAction.StartRadio -> {}
|
||||
is TrackOptionsAction.PlayNext -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
val existing = queue.find { entry ->
|
||||
queue.find { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (existing != null) {
|
||||
// Already in the local queue: move it to the next position
|
||||
audioPlayerQueue.removeFromQueue(existing)
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
} else {
|
||||
remotePlaybackController.requestTrackPlayNext(track)
|
||||
}
|
||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
@ -379,13 +381,33 @@ class PlaylistViewModel(
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||
viewModelScope.launch {
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||
remotePlaybackController.requestTracksPlayNext(tracks, title)
|
||||
viewModelScope.launch {
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
val savedTrackIds
|
||||
|
||||
@ -34,8 +34,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||
@ -99,7 +97,6 @@ class SavedTracksViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val shareService: ShareService,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val remotePlaybackController: RemotePlaybackController,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<SavedTracksViewModel>()
|
||||
|
||||
@ -203,20 +200,15 @@ class SavedTracksViewModel(
|
||||
}
|
||||
|
||||
fun playSavedTracks() {
|
||||
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
|
||||
viewModelScope.launch { playbackHelper.playSavedTracks() }
|
||||
}
|
||||
|
||||
fun addSavedTracksToQueue() {
|
||||
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
|
||||
viewModelScope.launch { playbackHelper.addSavedTracksToQueue() }
|
||||
}
|
||||
|
||||
fun playSavedTracksFromTrack(track: MetadataTrack) {
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
type = RemoteCollectionType.SavedTracks,
|
||||
id = SAVED_TRACKS_COLLECTION_ID,
|
||||
title = "Saved Tracks",
|
||||
startTrack = track,
|
||||
)
|
||||
viewModelScope.launch { playbackHelper.playSavedTracksFromTrack(track) }
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
@ -232,20 +224,14 @@ class SavedTracksViewModel(
|
||||
is TrackOptionsAction.StartRadio -> {}
|
||||
is TrackOptionsAction.PlayNext -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
val existing = queue.find { entry ->
|
||||
queue.find { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||
}
|
||||
if (existing != null) {
|
||||
// Already in the local queue: move it to the next position
|
||||
audioPlayerQueue.removeFromQueue(existing)
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
} else {
|
||||
remotePlaybackController.requestTrackPlayNext(track)
|
||||
}
|
||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
@ -313,11 +299,33 @@ class SavedTracksViewModel(
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks")
|
||||
viewModelScope.launch {
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks")
|
||||
viewModelScope.launch {
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isSavedTracks(trackIds: List<String>): List<Boolean> {
|
||||
|
||||
@ -86,7 +86,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
||||
import dev.krtirtho.spotube.core.ui.base.ChipTab
|
||||
@ -122,7 +121,6 @@ private val GridMinCellSize = 180.dp
|
||||
fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
||||
val shareService: ShareService = koinInject()
|
||||
val remotePlaybackController: RemotePlaybackController = koinInject()
|
||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||
val libraryRepository: LibraryRepository = koinInject()
|
||||
val blacklistRepository: BlacklistRepository = koinInject()
|
||||
@ -173,20 +171,14 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
is TrackOptionsAction.StartRadio -> {}
|
||||
is TrackOptionsAction.PlayNext -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
val existing = queue.find { entry ->
|
||||
queue.find { entry ->
|
||||
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
|
||||
}
|
||||
if (existing != null) {
|
||||
// Already in the local queue: move it to the next position
|
||||
audioPlayerQueue.removeFromQueue(existing)
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
} else {
|
||||
remotePlaybackController.requestTrackPlayNext(track)
|
||||
}
|
||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
||||
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
@ -245,11 +237,33 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
}
|
||||
|
||||
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
|
||||
scope.launch {
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllToQueue(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkPlayNext(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
|
||||
scope.launch {
|
||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
||||
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
|
||||
|
||||
val filteredTracks = tracks.filter { track ->
|
||||
track.id !in blacklistedTrackIds &&
|
||||
track.artists.none { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
|
||||
audioPlayerQueue.addAllAfterCurrent(entries)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
|
||||
@ -70,7 +70,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationState
|
||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost
|
||||
import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost
|
||||
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
|
||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
||||
@ -119,7 +118,6 @@ fun AppShell(
|
||||
}
|
||||
|
||||
ConnectionRequestDialogHost()
|
||||
PlayDestinationPickerHost()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val useSidebar = viewModel.useSidebar()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user