feat(remote-control): enhance remote playback functionality with new commands and UI integration

This commit is contained in:
Kingkor Roy Tirtho 2026-09-04 12:02:32 +06:00
parent 1c169d007a
commit 7bf17afc42
16 changed files with 1012 additions and 600 deletions

View File

@ -158,6 +158,7 @@ val sharedModules = module {
libraryRepository = get(),
shareService = get(),
downloadManager = get(),
remotePlaybackController = get(),
)
}
@ -173,6 +174,7 @@ val sharedModules = module {
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
remotePlaybackController = get(),
)
}
@ -196,6 +198,7 @@ val sharedModules = module {
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
remotePlaybackController = get(),
)
}
@ -208,6 +211,7 @@ val sharedModules = module {
albumRepository = get(),
playlistRepository = get(),
savedTracksRepository = get(),
artistRepository = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
)
@ -219,13 +223,13 @@ val sharedModules = module {
singleOf(::LocalServer) withOptions {
createdAtStart()
}
single { RemoteControlHandler(get(), get(), get()) }
single { RemoteControlHandler(get(), get(), get(), get()) }
single { RemoteControlClient() }
singleOf(::DeviceDiscoveryService)
single { RemoteControlService(get(), get(), get()) } withOptions {
createdAtStart()
}
single { RemotePlaybackController() }
single { RemotePlaybackController(get(), get(), get(), get()) }
single { JamSessionService(get(), get()) }
singleOf(::JamDeepLinkService)
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }

View File

@ -22,6 +22,7 @@ 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
@ -30,6 +31,7 @@ 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,
) {
@ -56,6 +58,13 @@ 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
@ -104,6 +113,13 @@ 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
@ -142,6 +158,32 @@ 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()) {
@ -243,6 +285,15 @@ 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>,

View File

@ -23,6 +23,7 @@ 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
@ -47,6 +48,7 @@ class RemoteControlHandler(
private val settingsRepository: SettingsRepository,
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
private val collectionPlaybackHelper: CollectionPlaybackHelper,
) : KoinComponent {
val logger by injectLogger<RemoteControlHandler>()
@ -170,7 +172,7 @@ class RemoteControlHandler(
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
when (val command = envelope.command) {
is RemoteControlCommand.Play -> {
logger.d { "Remote play request: ${command.source} (playback source not yet implemented)" }
handleCollectionSource(command.source, RemoteCollectionAction.Play)
}
is RemoteControlCommand.Pause -> {
audioPlayer.pause()
@ -213,7 +215,36 @@ class RemoteControlHandler(
audioPlayer.loop(loopState)
}
is RemoteControlCommand.AddToQueue -> {
logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" }
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)
@ -262,6 +293,55 @@ class RemoteControlHandler(
}
}
/**
* 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(
@ -366,6 +446,17 @@ 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,

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.core.remote
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -62,6 +63,30 @@ 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()

View File

@ -18,100 +18,307 @@
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.IO
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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
import org.koin.core.component.inject
enum class PlaybackDestinationAction {
Play,
AddToQueue,
PlayNext,
}
enum class RemoteCollectionType {
Playlist,
Album,
ArtistTopTracks,
SavedTracks,
}
/**
* Manages the play destination picker state and remote playback commands.
* Injected into ViewModels to handle playback actions when a remote device is connected.
* A playback request awaiting a destination choice (local device vs a connected
* remote device). [title] is the content label shown in the picker dialog.
*/
class RemotePlaybackController : KoinComponent {
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 {
private val logger = Logger.withTag("RemotePlaybackController")
private val remoteControlClient: RemoteControlClient by inject()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _showPicker = MutableStateFlow(false)
val showPicker: StateFlow<Boolean> = _showPicker.asStateFlow()
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null)
val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _pendingRequest.asStateFlow()
private var pendingAction: (() -> Unit)? = null
/**
* Checks if a remote device is connected.
*/
fun isRemoteConnected(): Boolean {
return remoteControlClient.connectionState.value is ConnectionState.Connected
}
/**
* 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()) {
pendingAction = action
_showPicker.value = true
} else {
action()
}
// ---------- Collection actions ----------
fun requestCollectionPlay(
type: RemoteCollectionType,
id: String,
title: String,
startTrack: MetadataTrack? = null,
) {
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack))
}
/**
* Called when the user chooses to play locally.
*/
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() {
_showPicker.value = false
pendingAction?.invoke()
pendingAction = null
val request = _pendingRequest.value ?: return
_pendingRequest.value = null
executeLocally(request)
}
/**
* 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 {
remoteControlClient.sendCommand(RemoteControlCommand.Play(source))
logger.i { "Sent play command for source: $source" }
} catch (e: Exception) {
logger.e(e) { "Failed to send play command" }
}
}
fun playOnRemote() {
val request = _pendingRequest.value ?: return
_pendingRequest.value = null
executeOnRemote(request)
}
/**
* Called when the user dismisses the picker.
*/
fun dismissPicker() {
_showPicker.value = false
pendingAction = null
_pendingRequest.value = null
}
/**
* 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" }
// ---------- Internals ----------
private fun request(request: PlaybackDestinationRequest) {
if (isRemoteConnected()) {
_pendingRequest.value = request
} else {
executeLocally(request)
}
}
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)
}
}
}
}
}
}
}
private fun executeOnRemote(request: PlaybackDestinationRequest) {
scope.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}" }
} catch (e: Exception) {
logger.e(e) { "Failed to send remote playback 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 }
}
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 } }
}
}

View File

@ -20,7 +20,6 @@ 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
@ -29,8 +28,9 @@ 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()
navigationCommands: NavigationCommands = koinInject(),
remotePlaybackController: RemotePlaybackController = koinInject(),
) {
val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
PlayableCard(
@ -54,10 +54,18 @@ fun AlbumCard(
},
onPlay = {
if (currentCollectionEntry?.id == album.id) return@PlayableCard
scope.launch { playbackHelper.playAlbum(album.id) }
remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Album,
album.id,
album.title,
)
},
onAddToQueue = {
scope.launch { playbackHelper.addAlbumToQueue(album.id) }
remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Album,
album.id,
album.title,
)
},
modifier = modifier.width(160.dp),
sharedElementKey = "album_art_${album.id}",

View File

@ -27,6 +27,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
@ -37,7 +39,8 @@ fun PlaylistCard(
modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject()
navigationCommands: NavigationCommands = koinInject(),
remotePlaybackController: RemotePlaybackController = koinInject(),
) {
val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
@ -52,10 +55,18 @@ fun PlaylistCard(
},
onPlay = {
if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard
scope.launch { playbackHelper.playPlaylist(playlist.id) }
remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Playlist,
playlist.id,
playlist.title,
)
},
onAddToQueue = {
scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) }
remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Playlist,
playlist.id,
playlist.title,
)
},
modifier = modifier,
sharedElementKey = "playlist_art_${playlist.id}",

View File

@ -26,6 +26,8 @@ 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
@ -97,6 +99,7 @@ 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>()
@ -217,15 +220,28 @@ class AlbumViewModel(
}
fun playAlbum() {
viewModelScope.launch { playbackHelper.playAlbum(albumId) }
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title)
}
fun addAlbumToQueue() {
viewModelScope.launch { playbackHelper.addAlbumToQueue(albumId) }
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)
}
fun playAlbumFromTrack(track: MetadataTrack) {
viewModelScope.launch { playbackHelper.playAlbumFromTrack(albumId, track) }
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestCollectionPlay(
type = RemoteCollectionType.Album,
id = albumId,
title = title,
startTrack = track,
)
}
fun refresh() {
@ -241,14 +257,20 @@ class AlbumViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val existing = queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
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)
}
}
is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
@ -312,33 +334,13 @@ class AlbumViewModel(
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
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)
}
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestTracksAddToQueue(tracks, title)
}
fun playTracksNext(tracks: List<MetadataTrack>) {
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 title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestTracksPlayNext(tracks, title)
}
fun isTrackBlacklisted(track: MetadataTrack): Boolean {

View File

@ -27,6 +27,8 @@ 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
@ -73,6 +75,7 @@ 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>()
@ -247,80 +250,40 @@ class ArtistViewModel(
}
fun addTopTracksToQueue() {
viewModelScope.launch {
val entries = resolveTopTrackEntries()
if (entries.isEmpty()) return@launch
audioPlayerQueue.addAllToQueue(entries)
}
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.ArtistTopTracks,
artistId,
artistName,
)
}
fun playTopTracks() {
viewModelScope.launch {
val entries = resolveTopTrackEntries()
if (entries.isEmpty()) return@launch
audioPlayerQueue.load(
entries = entries,
autoPlay = true,
startPosition = 0,
collectionEntry = null,
)
}
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.ArtistTopTracks,
artistId,
artistName,
)
}
fun playTopTracksFromTrack(track: MetadataTrack) {
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,
)
}
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestCollectionPlay(
type = RemoteCollectionType.ArtistTopTracks,
id = artistId,
title = artistName,
startTrack = track,
)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
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)
}
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
}
fun playTracksNext(tracks: List<MetadataTrack>) {
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 artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestTracksPlayNext(tracks, artistName)
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
@ -329,13 +292,19 @@ class ArtistViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val existing = queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
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)
}
}
is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue()

View File

@ -17,8 +17,10 @@
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
@ -26,38 +28,49 @@ 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
/**
* 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.
* 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).
*/
@Composable
fun PlayDestinationPicker(
visible: Boolean,
onDismiss: () -> Unit,
onPlayLocally: () -> Unit,
onPlayOnRemote: () -> Unit,
) {
fun PlayDestinationPickerHost() {
val controller = koinInject<RemotePlaybackController>()
val remoteControlClient = koinInject<RemoteControlClient>()
val request by controller.pendingRequest.collectAsStateWithLifecycle()
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
if (!visible) return
val pendingRequest = request ?: 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 = onDismiss,
onDismissRequest = controller::dismissPicker,
title = {
Text(
text = "Play Where?",
text = "Where to $actionLabel?",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
@ -65,24 +78,69 @@ fun PlayDestinationPicker(
content = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Choose where to play this content:",
text = "$actionLabel \"${pendingRequest.title}\" on:",
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 = onDismiss) {
TextButton(onClick = controller::dismissPicker) {
Text("Cancel")
}
TextButton(onClick = onPlayLocally) {
Text("This Device")
}
TextButton(onClick = onPlayOnRemote) {
Text(remoteDeviceName)
}
},
)
}
}

View File

@ -18,6 +18,8 @@
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
@ -25,11 +27,13 @@ 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
@ -97,101 +101,131 @@ fun RemoteControlScreen(
val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle()
val shellBottomInset = LocalAppShellBottomInset.current
Scaffold(
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
},
)
}
GhostIconButton(
onClick = {
viewModel.disconnect()
onDisconnect()
// 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
},
)
}
GhostIconButton(
onClick = {
viewModel.disconnect()
onDisconnect()
}
) {
Icon(
imageVector = Iconsax.IconsaxCloseSquare,
contentDescription = "Disconnect",
)
}
) {
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...")
) { 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.Disconnected -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center
) {
Text("Disconnected")
is ConnectionState.Connecting -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center
) {
Text("Connecting...")
}
}
}
is ConnectionState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center
) {
Text("Connection error: ${(connectionState as ConnectionState.Error).message}")
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}")
}
}
}
}
}
QueueSheet(
isVisible = isQueueVisible,
onDismiss = { viewModel.toggleQueueVisibility() },
modifier = Modifier.fillMaxSize(),
) {
RemoteQueueSection(
queueState = queueState,
onPlayQueueItem = viewModel::playQueueItem,
onRemoveQueueItem = viewModel::removeQueueItem,
// 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,
)
}
}
}
@Composable
@ -206,227 +240,230 @@ private fun RemoteControlContent(
onCycleLoopMode: () -> Unit,
modifier: Modifier = Modifier,
) {
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))
.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(32.dp))
// Track info
Box (modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.fillMaxWidth(),
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.widthIn(max = 480.dp)
.align(Alignment.TopCenter),
horizontalAlignment = Alignment.CenterHorizontally,
) {
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(32.dp))
Spacer(modifier = Modifier.height(8.dp))
// 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),
)
}
}
}
Text(
text = playerState.currentTrackArtists ?: "Unknown Artist",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(32.dp))
if (playerState.currentTrackAlbum != null) {
Spacer(modifier = Modifier.height(4.dp))
// Track info
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = playerState.currentTrackAlbum!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
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,
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(
modifier = Modifier.fillMaxWidth(),
) {
Slider(
value = playerState.positionMs.toFloat(),
onValueChange = { onSeek(it.toLong()) },
valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f),
// 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,
) {
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))
// Playback controls
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatDuration(playerState.positionMs),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// 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),
) {
Icon(
imageVector = Iconsax.IconsaxVolumeHigh,
contentDescription = "Volume",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
Text(
text = formatDuration(playerState.durationMs),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
Slider(
value = playerState.volume,
onValueChange = onSetVolume,
valueRange = 0f..1f,
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(32.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),
) {
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))
}
}

View File

@ -38,7 +38,6 @@ 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
@ -60,7 +59,6 @@ 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) }
@ -171,13 +169,6 @@ fun PlaylistScreen(
viewModel.refresh()
},
)
PlayDestinationPicker(
visible = showPlayDestinationPicker,
onDismiss = viewModel::dismissPlayPicker,
onPlayLocally = viewModel::playLocally,
onPlayOnRemote = viewModel::playOnRemote,
)
},
)
}

View File

@ -26,6 +26,7 @@ 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
@ -117,8 +118,6 @@ 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()
@ -227,35 +226,28 @@ class PlaylistViewModel(
}
fun playPlaylist() {
remotePlaybackController.wrapPlaybackAction {
viewModelScope.launch { playbackHelper.playPlaylist(playlistId) }
}
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title)
}
fun addPlaylistToQueue() {
if (remotePlaybackController.isRemoteConnected()) {
remotePlaybackController.addToQueueOnRemote(playlistId)
} else {
viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) }
}
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)
}
fun playPlaylistFromTrack(track: MetadataTrack) {
remotePlaybackController.wrapPlaybackAction {
viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) }
}
}
fun playLocally() {
remotePlaybackController.playLocally()
}
fun playOnRemote() {
remotePlaybackController.playOnRemote(playlistId)
}
fun dismissPlayPicker() {
remotePlaybackController.dismissPicker()
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionPlay(
type = RemoteCollectionType.Playlist,
id = playlistId,
title = title,
startTrack = track,
)
}
fun refresh() {
@ -298,14 +290,20 @@ class PlaylistViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val existing = queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
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)
}
}
is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
@ -381,33 +379,13 @@ class PlaylistViewModel(
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
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)
}
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestTracksAddToQueue(tracks, title)
}
fun playTracksNext(tracks: List<MetadataTrack>) {
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 title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestTracksPlayNext(tracks, title)
}
val savedTrackIds

View File

@ -34,6 +34,8 @@ 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
@ -97,6 +99,7 @@ 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>()
@ -200,15 +203,20 @@ class SavedTracksViewModel(
}
fun playSavedTracks() {
viewModelScope.launch { playbackHelper.playSavedTracks() }
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
}
fun addSavedTracksToQueue() {
viewModelScope.launch { playbackHelper.addSavedTracksToQueue() }
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
}
fun playSavedTracksFromTrack(track: MetadataTrack) {
viewModelScope.launch { playbackHelper.playSavedTracksFromTrack(track) }
remotePlaybackController.requestCollectionPlay(
type = RemoteCollectionType.SavedTracks,
id = SAVED_TRACKS_COLLECTION_ID,
title = "Saved Tracks",
startTrack = track,
)
}
fun refresh() {
@ -224,14 +232,20 @@ class SavedTracksViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val existing = queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
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)
}
}
is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
@ -299,33 +313,11 @@ class SavedTracksViewModel(
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
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)
}
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks")
}
fun playTracksNext(tracks: List<MetadataTrack>) {
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)
}
remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks")
}
suspend fun isSavedTracks(trackIds: List<String>): List<Boolean> {

View File

@ -86,6 +86,7 @@ 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
@ -121,6 +122,7 @@ 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()
@ -171,14 +173,20 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val existing = queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
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)
}
}
is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
@ -237,33 +245,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
}
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
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)
}
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
}
fun bulkPlayNext(tracks: List<MetadataTrack>) {
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)
}
remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
}
Scaffold(

View File

@ -70,6 +70,7 @@ 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
@ -118,6 +119,7 @@ fun AppShell(
}
ConnectionRequestDialogHost()
PlayDestinationPickerHost()
Box(modifier = Modifier.fillMaxSize()) {
val useSidebar = viewModel.useSidebar()