mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
feat(remote-control): enhance remote playback functionality with new commands and UI integration
This commit is contained in:
parent
1c169d007a
commit
7bf17afc42
@ -158,6 +158,7 @@ val sharedModules = module {
|
|||||||
libraryRepository = get(),
|
libraryRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
|
remotePlaybackController = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,6 +174,7 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
|
remotePlaybackController = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,6 +198,7 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
|
remotePlaybackController = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,6 +211,7 @@ val sharedModules = module {
|
|||||||
albumRepository = get(),
|
albumRepository = get(),
|
||||||
playlistRepository = get(),
|
playlistRepository = get(),
|
||||||
savedTracksRepository = get(),
|
savedTracksRepository = get(),
|
||||||
|
artistRepository = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
)
|
)
|
||||||
@ -219,13 +223,13 @@ val sharedModules = module {
|
|||||||
singleOf(::LocalServer) withOptions {
|
singleOf(::LocalServer) withOptions {
|
||||||
createdAtStart()
|
createdAtStart()
|
||||||
}
|
}
|
||||||
single { RemoteControlHandler(get(), get(), get()) }
|
single { RemoteControlHandler(get(), get(), get(), get()) }
|
||||||
single { RemoteControlClient() }
|
single { RemoteControlClient() }
|
||||||
singleOf(::DeviceDiscoveryService)
|
singleOf(::DeviceDiscoveryService)
|
||||||
single { RemoteControlService(get(), get(), get()) } withOptions {
|
single { RemoteControlService(get(), get(), get()) } withOptions {
|
||||||
createdAtStart()
|
createdAtStart()
|
||||||
}
|
}
|
||||||
single { RemotePlaybackController() }
|
single { RemotePlaybackController(get(), get(), get(), get()) }
|
||||||
single { JamSessionService(get(), get()) }
|
single { JamSessionService(get(), get()) }
|
||||||
singleOf(::JamDeepLinkService)
|
singleOf(::JamDeepLinkService)
|
||||||
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
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.blacklist.BlacklistRepository
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
@ -30,6 +31,7 @@ class CollectionPlaybackHelper(
|
|||||||
private val albumRepository: AlbumRepository,
|
private val albumRepository: AlbumRepository,
|
||||||
private val playlistRepository: PlaylistRepository,
|
private val playlistRepository: PlaylistRepository,
|
||||||
private val savedTracksRepository: SavedTracksRepository,
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
|
private val artistRepository: ArtistRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
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) {
|
suspend fun playAlbumFromTrack(albumId: String, track: MetadataTrack) {
|
||||||
val entries = fetchAllAlbumTracks(albumId)
|
val entries = fetchAllAlbumTracks(albumId)
|
||||||
if (entries.isEmpty()) return
|
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) {
|
suspend fun playPlaylistFromTrack(playlistId: String, track: MetadataTrack) {
|
||||||
val entries = fetchAllPlaylistTracks(playlistId)
|
val entries = fetchAllPlaylistTracks(playlistId)
|
||||||
if (entries.isEmpty()) return
|
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() {
|
suspend fun addSavedTracksToQueue() {
|
||||||
val entries = fetchAllSavedTracks()
|
val entries = fetchAllSavedTracks()
|
||||||
if (entries.isNotEmpty()) {
|
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(
|
private fun isTrackBlacklisted(
|
||||||
track: MetadataTrack,
|
track: MetadataTrack,
|
||||||
blacklistedTrackIds: Set<String>,
|
blacklistedTrackIds: Set<String>,
|
||||||
|
|||||||
@ -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.PlayerState as AudioPlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
|
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
import io.ktor.server.websocket.WebSocketServerSession
|
import io.ktor.server.websocket.WebSocketServerSession
|
||||||
import io.ktor.websocket.CloseReason
|
import io.ktor.websocket.CloseReason
|
||||||
@ -47,6 +48,7 @@ class RemoteControlHandler(
|
|||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
|
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
val logger by injectLogger<RemoteControlHandler>()
|
val logger by injectLogger<RemoteControlHandler>()
|
||||||
|
|
||||||
@ -170,7 +172,7 @@ class RemoteControlHandler(
|
|||||||
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
|
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
|
||||||
when (val command = envelope.command) {
|
when (val command = envelope.command) {
|
||||||
is RemoteControlCommand.Play -> {
|
is RemoteControlCommand.Play -> {
|
||||||
logger.d { "Remote play request: ${command.source} (playback source not yet implemented)" }
|
handleCollectionSource(command.source, RemoteCollectionAction.Play)
|
||||||
}
|
}
|
||||||
is RemoteControlCommand.Pause -> {
|
is RemoteControlCommand.Pause -> {
|
||||||
audioPlayer.pause()
|
audioPlayer.pause()
|
||||||
@ -213,7 +215,36 @@ class RemoteControlHandler(
|
|||||||
audioPlayer.loop(loopState)
|
audioPlayer.loop(loopState)
|
||||||
}
|
}
|
||||||
is RemoteControlCommand.AddToQueue -> {
|
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 -> {
|
is RemoteControlCommand.PlayIndex -> {
|
||||||
audioPlayerQueue.jumpTo(command.index)
|
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) {
|
private suspend fun broadcastState(session: WebSocketServerSession) {
|
||||||
val current = audioPlayerQueue.currentQueueEntryFlow.value
|
val current = audioPlayerQueue.currentQueueEntryFlow.value
|
||||||
val state = RemoteControlEvent.PlayerState(
|
val state = RemoteControlEvent.PlayerState(
|
||||||
@ -366,6 +446,17 @@ data class CommandEnvelope(
|
|||||||
val command: RemoteControlCommand,
|
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(
|
data class ConnectionRequest(
|
||||||
val deviceId: String,
|
val deviceId: String,
|
||||||
val deviceName: String,
|
val deviceName: String,
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.remote
|
package dev.krtirtho.spotube.core.remote
|
||||||
|
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@ -62,6 +63,30 @@ sealed class RemoteControlCommand {
|
|||||||
@SerialName("addToQueue")
|
@SerialName("addToQueue")
|
||||||
data class AddToQueue(val source: String) : RemoteControlCommand()
|
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
|
@Serializable
|
||||||
@SerialName("playIndex")
|
@SerialName("playIndex")
|
||||||
data class PlayIndex(val index: Int) : RemoteControlCommand()
|
data class PlayIndex(val index: Int) : RemoteControlCommand()
|
||||||
|
|||||||
@ -18,100 +18,307 @@
|
|||||||
package dev.krtirtho.spotube.core.remote
|
package dev.krtirtho.spotube.core.remote
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
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.
|
* A playback request awaiting a destination choice (local device vs a connected
|
||||||
* Injected into ViewModels to handle playback actions when a remote device is 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 logger = Logger.withTag("RemotePlaybackController")
|
||||||
private val remoteControlClient: RemoteControlClient by inject()
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
private val _showPicker = MutableStateFlow(false)
|
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null)
|
||||||
val showPicker: StateFlow<Boolean> = _showPicker.asStateFlow()
|
val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _pendingRequest.asStateFlow()
|
||||||
|
|
||||||
private var pendingAction: (() -> Unit)? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a remote device is connected.
|
|
||||||
*/
|
|
||||||
fun isRemoteConnected(): Boolean {
|
fun isRemoteConnected(): Boolean {
|
||||||
return remoteControlClient.connectionState.value is ConnectionState.Connected
|
return remoteControlClient.connectionState.value is ConnectionState.Connected
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ---------- Collection actions ----------
|
||||||
* Wraps a playback action. If a remote device is connected, shows the picker.
|
|
||||||
* Otherwise, executes the action immediately.
|
fun requestCollectionPlay(
|
||||||
*
|
type: RemoteCollectionType,
|
||||||
* @param action The action to execute if playing locally
|
id: String,
|
||||||
*/
|
title: String,
|
||||||
fun wrapPlaybackAction(action: () -> Unit) {
|
startTrack: MetadataTrack? = null,
|
||||||
if (isRemoteConnected()) {
|
) {
|
||||||
pendingAction = action
|
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack))
|
||||||
_showPicker.value = true
|
|
||||||
} else {
|
|
||||||
action()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) {
|
||||||
* Called when the user chooses to play locally.
|
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() {
|
fun playLocally() {
|
||||||
_showPicker.value = false
|
val request = _pendingRequest.value ?: return
|
||||||
pendingAction?.invoke()
|
_pendingRequest.value = null
|
||||||
pendingAction = null
|
executeLocally(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun playOnRemote() {
|
||||||
* Called when the user chooses to play on the remote device.
|
val request = _pendingRequest.value ?: return
|
||||||
* Sends a play command to the remote device.
|
_pendingRequest.value = null
|
||||||
*
|
executeOnRemote(request)
|
||||||
* @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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the user dismisses the picker.
|
|
||||||
*/
|
|
||||||
fun dismissPicker() {
|
fun dismissPicker() {
|
||||||
_showPicker.value = false
|
_pendingRequest.value = null
|
||||||
pendingAction = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ---------- Internals ----------
|
||||||
* Sends an add-to-queue command to the remote device.
|
|
||||||
*
|
private fun request(request: PlaybackDestinationRequest) {
|
||||||
* @param source The source identifier (e.g., playlist ID, album ID, track ID)
|
if (isRemoteConnected()) {
|
||||||
*/
|
_pendingRequest.value = request
|
||||||
fun addToQueueOnRemote(source: String) {
|
} else {
|
||||||
CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
|
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 {
|
try {
|
||||||
remoteControlClient.sendCommand(RemoteControlCommand.AddToQueue(source))
|
val command = when (request) {
|
||||||
logger.i { "Sent add-to-queue command for source: $source" }
|
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) {
|
} catch (e: Exception) {
|
||||||
logger.e(e) { "Failed to send add-to-queue command" }
|
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 } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -20,7 +20,6 @@ package dev.krtirtho.spotube.core.ui.component
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
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 dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -39,9 +39,9 @@ fun AlbumCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
navigationCommands: NavigationCommands = koinInject()
|
navigationCommands: NavigationCommands = koinInject(),
|
||||||
|
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
PlayableCard(
|
PlayableCard(
|
||||||
@ -54,10 +54,18 @@ fun AlbumCard(
|
|||||||
},
|
},
|
||||||
onPlay = {
|
onPlay = {
|
||||||
if (currentCollectionEntry?.id == album.id) return@PlayableCard
|
if (currentCollectionEntry?.id == album.id) return@PlayableCard
|
||||||
scope.launch { playbackHelper.playAlbum(album.id) }
|
remotePlaybackController.requestCollectionPlay(
|
||||||
|
RemoteCollectionType.Album,
|
||||||
|
album.id,
|
||||||
|
album.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
scope.launch { playbackHelper.addAlbumToQueue(album.id) }
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
|
RemoteCollectionType.Album,
|
||||||
|
album.id,
|
||||||
|
album.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier = modifier.width(160.dp),
|
modifier = modifier.width(160.dp),
|
||||||
sharedElementKey = "album_art_${album.id}",
|
sharedElementKey = "album_art_${album.id}",
|
||||||
|
|||||||
@ -27,6 +27,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
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 dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
@ -37,7 +39,8 @@ fun PlaylistCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
navigationCommands: NavigationCommands = koinInject()
|
navigationCommands: NavigationCommands = koinInject(),
|
||||||
|
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
@ -52,10 +55,18 @@ fun PlaylistCard(
|
|||||||
},
|
},
|
||||||
onPlay = {
|
onPlay = {
|
||||||
if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard
|
if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard
|
||||||
scope.launch { playbackHelper.playPlaylist(playlist.id) }
|
remotePlaybackController.requestCollectionPlay(
|
||||||
|
RemoteCollectionType.Playlist,
|
||||||
|
playlist.id,
|
||||||
|
playlist.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) }
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
|
RemoteCollectionType.Playlist,
|
||||||
|
playlist.id,
|
||||||
|
playlist.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
sharedElementKey = "playlist_art_${playlist.id}",
|
sharedElementKey = "playlist_art_${playlist.id}",
|
||||||
|
|||||||
@ -26,6 +26,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
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.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||||
@ -97,6 +99,7 @@ class AlbumViewModel(
|
|||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
|
private val remotePlaybackController: RemotePlaybackController,
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<AlbumViewModel>()
|
private val logger by injectLogger<AlbumViewModel>()
|
||||||
|
|
||||||
@ -217,15 +220,28 @@ class AlbumViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun playAlbum() {
|
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() {
|
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) {
|
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() {
|
fun refresh() {
|
||||||
@ -241,14 +257,20 @@ class AlbumViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
val existing = queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
}
|
||||||
|
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 = "")))
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
|
} else {
|
||||||
|
remotePlaybackController.requestTrackPlayNext(track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
@ -312,33 +334,13 @@ class AlbumViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||||
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>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksPlayNext(tracks, title)
|
||||||
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 {
|
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||||
|
|||||||
@ -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.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
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.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||||
@ -73,6 +75,7 @@ class ArtistViewModel(
|
|||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
|
private val remotePlaybackController: RemotePlaybackController,
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<ArtistViewModel>()
|
private val logger by injectLogger<ArtistViewModel>()
|
||||||
|
|
||||||
@ -247,80 +250,40 @@ class ArtistViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addTopTracksToQueue() {
|
fun addTopTracksToQueue() {
|
||||||
viewModelScope.launch {
|
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||||
val entries = resolveTopTrackEntries()
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
if (entries.isEmpty()) return@launch
|
RemoteCollectionType.ArtistTopTracks,
|
||||||
audioPlayerQueue.addAllToQueue(entries)
|
artistId,
|
||||||
}
|
artistName,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTopTracks() {
|
fun playTopTracks() {
|
||||||
viewModelScope.launch {
|
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||||
val entries = resolveTopTrackEntries()
|
remotePlaybackController.requestCollectionPlay(
|
||||||
if (entries.isEmpty()) return@launch
|
RemoteCollectionType.ArtistTopTracks,
|
||||||
audioPlayerQueue.load(
|
artistId,
|
||||||
entries = entries,
|
artistName,
|
||||||
autoPlay = true,
|
|
||||||
startPosition = 0,
|
|
||||||
collectionEntry = null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun playTopTracksFromTrack(track: MetadataTrack) {
|
fun playTopTracksFromTrack(track: MetadataTrack) {
|
||||||
viewModelScope.launch {
|
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||||
val queue = audioPlayerQueue.getQueue()
|
remotePlaybackController.requestCollectionPlay(
|
||||||
val queueIndex = queue.indexOfFirst { entry ->
|
type = RemoteCollectionType.ArtistTopTracks,
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
id = artistId,
|
||||||
}
|
title = artistName,
|
||||||
if (queueIndex >= 0) {
|
startTrack = track,
|
||||||
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>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
|
||||||
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>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksPlayNext(tracks, artistName)
|
||||||
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) {
|
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||||
@ -329,13 +292,19 @@ class ArtistViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
val existing = queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
}
|
||||||
|
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 = "")))
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
|
} else {
|
||||||
|
remotePlaybackController.requestTrackPlayNext(track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
}
|
}
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
|
|||||||
@ -17,8 +17,10 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.devices
|
package dev.krtirtho.spotube.modules.devices
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@ -26,38 +28,49 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.krtirtho.spotube.core.remote.ConnectionState
|
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.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.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
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dialog shown when a remote device is connected and the user tries to play/add to queue.
|
* Globally hosted dialog shown when a remote device is connected and the user
|
||||||
* Allows the user to choose between playing on the local device or the remote device.
|
* tries to play / add to queue / play next. Lets the user choose between the
|
||||||
|
* local device and the connected remote device(s).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayDestinationPicker(
|
fun PlayDestinationPickerHost() {
|
||||||
visible: Boolean,
|
val controller = koinInject<RemotePlaybackController>()
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onPlayLocally: () -> Unit,
|
|
||||||
onPlayOnRemote: () -> Unit,
|
|
||||||
) {
|
|
||||||
val remoteControlClient = koinInject<RemoteControlClient>()
|
val remoteControlClient = koinInject<RemoteControlClient>()
|
||||||
|
val request by controller.pendingRequest.collectAsStateWithLifecycle()
|
||||||
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
|
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
if (!visible) return
|
val pendingRequest = request ?: return
|
||||||
|
|
||||||
val remoteDeviceName = when (val state = connectionState) {
|
val remoteDeviceName = when (val state = connectionState) {
|
||||||
is ConnectionState.Connected -> "Remote Device (${state.host})"
|
is ConnectionState.Connected -> "Remote Device (${state.host})"
|
||||||
else -> "Remote Device"
|
else -> "Remote Device"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val actionLabel = when (pendingRequest.action) {
|
||||||
|
PlaybackDestinationAction.Play -> "Play"
|
||||||
|
PlaybackDestinationAction.AddToQueue -> "Add to queue"
|
||||||
|
PlaybackDestinationAction.PlayNext -> "Play next"
|
||||||
|
}
|
||||||
|
|
||||||
ThemedDialog(
|
ThemedDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = controller::dismissPicker,
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = "Play Where?",
|
text = "Where to $actionLabel?",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
@ -65,24 +78,69 @@ fun PlayDestinationPicker(
|
|||||||
content = {
|
content = {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Choose where to play this content:",
|
text = "$actionLabel \"${pendingRequest.title}\" on:",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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 = {
|
actions = {
|
||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = controller::dismissPicker) {
|
||||||
Text("Cancel")
|
Text("Cancel")
|
||||||
}
|
}
|
||||||
TextButton(onClick = onPlayLocally) {
|
|
||||||
Text("This Device")
|
|
||||||
}
|
|
||||||
TextButton(onClick = onPlayOnRemote) {
|
|
||||||
Text(remoteDeviceName)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -18,6 +18,8 @@
|
|||||||
package dev.krtirtho.spotube.modules.devices
|
package dev.krtirtho.spotube.modules.devices
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@ -97,7 +101,15 @@ fun RemoteControlScreen(
|
|||||||
val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle()
|
val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle()
|
||||||
val shellBottomInset = LocalAppShellBottomInset.current
|
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(
|
Scaffold(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight(),
|
||||||
topBar = {
|
topBar = {
|
||||||
ApplicationMainBar(
|
ApplicationMainBar(
|
||||||
title = { Text("Remote Control") },
|
title = { Text("Remote Control") },
|
||||||
@ -180,7 +192,28 @@ fun RemoteControlScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
QueueSheet(
|
||||||
isVisible = isQueueVisible,
|
isVisible = isQueueVisible,
|
||||||
onDismiss = { viewModel.toggleQueueVisibility() },
|
onDismiss = { viewModel.toggleQueueVisibility() },
|
||||||
@ -193,6 +226,7 @@ fun RemoteControlScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RemoteControlContent(
|
private fun RemoteControlContent(
|
||||||
@ -206,11 +240,13 @@ private fun RemoteControlContent(
|
|||||||
onCycleLoopMode: () -> Unit,
|
onCycleLoopMode: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
Box (modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp)
|
||||||
|
.widthIn(max = 480.dp)
|
||||||
|
.align(Alignment.TopCenter),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
@ -429,6 +465,7 @@ private fun RemoteControlContent(
|
|||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RemoteQueueSection(
|
private fun RemoteQueueSection(
|
||||||
|
|||||||
@ -38,7 +38,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
|||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
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.AddToPlaylistPicker
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
|
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
|
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
|
||||||
@ -60,7 +59,6 @@ fun PlaylistScreen(
|
|||||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||||
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||||
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||||
val showPlayDestinationPicker by viewModel.showPlayDestinationPicker.collectAsStateWithLifecycle()
|
|
||||||
var showEditPlaylist by remember { mutableStateOf(false) }
|
var showEditPlaylist by remember { mutableStateOf(false) }
|
||||||
var showAddTracksDialog by remember { mutableStateOf(false) }
|
var showAddTracksDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -171,13 +169,6 @@ fun PlaylistScreen(
|
|||||||
viewModel.refresh()
|
viewModel.refresh()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
PlayDestinationPicker(
|
|
||||||
visible = showPlayDestinationPicker,
|
|
||||||
onDismiss = viewModel::dismissPlayPicker,
|
|
||||||
onPlayLocally = viewModel::playLocally,
|
|
||||||
onPlayOnRemote = viewModel::playOnRemote,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
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.remote.RemotePlaybackController
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
@ -117,8 +118,6 @@ class PlaylistViewModel(
|
|||||||
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
|
||||||
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
|
||||||
|
|
||||||
val showPlayDestinationPicker = remotePlaybackController.showPicker
|
|
||||||
|
|
||||||
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
|
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
|
||||||
private val _showAddToPlaylistPicker = MutableStateFlow(false)
|
private val _showAddToPlaylistPicker = MutableStateFlow(false)
|
||||||
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
|
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
|
||||||
@ -227,35 +226,28 @@ class PlaylistViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun playPlaylist() {
|
fun playPlaylist() {
|
||||||
remotePlaybackController.wrapPlaybackAction {
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
viewModelScope.launch { playbackHelper.playPlaylist(playlistId) }
|
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addPlaylistToQueue() {
|
fun addPlaylistToQueue() {
|
||||||
if (remotePlaybackController.isRemoteConnected()) {
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
remotePlaybackController.addToQueueOnRemote(playlistId)
|
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title)
|
||||||
} else {
|
|
||||||
viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun playPlaylistNext() {
|
||||||
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
|
remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Playlist, playlistId, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playPlaylistFromTrack(track: MetadataTrack) {
|
fun playPlaylistFromTrack(track: MetadataTrack) {
|
||||||
remotePlaybackController.wrapPlaybackAction {
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) }
|
remotePlaybackController.requestCollectionPlay(
|
||||||
}
|
type = RemoteCollectionType.Playlist,
|
||||||
}
|
id = playlistId,
|
||||||
|
title = title,
|
||||||
fun playLocally() {
|
startTrack = track,
|
||||||
remotePlaybackController.playLocally()
|
)
|
||||||
}
|
|
||||||
|
|
||||||
fun playOnRemote() {
|
|
||||||
remotePlaybackController.playOnRemote(playlistId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissPlayPicker() {
|
|
||||||
remotePlaybackController.dismissPicker()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
@ -298,14 +290,20 @@ class PlaylistViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
val existing = queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
}
|
||||||
|
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 = "")))
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
|
} else {
|
||||||
|
remotePlaybackController.requestTrackPlayNext(track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
@ -381,33 +379,13 @@ class PlaylistViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||||
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>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksPlayNext(tracks, title)
|
||||||
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
|
val savedTrackIds
|
||||||
|
|||||||
@ -34,6 +34,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
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.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||||
@ -97,6 +99,7 @@ class SavedTracksViewModel(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
|
private val remotePlaybackController: RemotePlaybackController,
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<SavedTracksViewModel>()
|
private val logger by injectLogger<SavedTracksViewModel>()
|
||||||
|
|
||||||
@ -200,15 +203,20 @@ class SavedTracksViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun playSavedTracks() {
|
fun playSavedTracks() {
|
||||||
viewModelScope.launch { playbackHelper.playSavedTracks() }
|
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSavedTracksToQueue() {
|
fun addSavedTracksToQueue() {
|
||||||
viewModelScope.launch { playbackHelper.addSavedTracksToQueue() }
|
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playSavedTracksFromTrack(track: MetadataTrack) {
|
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() {
|
fun refresh() {
|
||||||
@ -224,14 +232,20 @@ class SavedTracksViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
val existing = queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
}
|
||||||
|
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 = "")))
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
|
} else {
|
||||||
|
remotePlaybackController.requestTrackPlayNext(track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
@ -299,33 +313,11 @@ class SavedTracksViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks")
|
||||||
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>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks")
|
||||||
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> {
|
suspend fun isSavedTracks(trackIds: List<String>): List<Boolean> {
|
||||||
|
|||||||
@ -86,6 +86,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
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.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
||||||
import dev.krtirtho.spotube.core.ui.base.ChipTab
|
import dev.krtirtho.spotube.core.ui.base.ChipTab
|
||||||
@ -121,6 +122,7 @@ private val GridMinCellSize = 180.dp
|
|||||||
fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||||
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
||||||
val shareService: ShareService = koinInject()
|
val shareService: ShareService = koinInject()
|
||||||
|
val remotePlaybackController: RemotePlaybackController = koinInject()
|
||||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||||
val libraryRepository: LibraryRepository = koinInject()
|
val libraryRepository: LibraryRepository = koinInject()
|
||||||
val blacklistRepository: BlacklistRepository = koinInject()
|
val blacklistRepository: BlacklistRepository = koinInject()
|
||||||
@ -171,14 +173,20 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
val existing = queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
|
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
|
||||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
}
|
||||||
|
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 = "")))
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
|
} else {
|
||||||
|
remotePlaybackController.requestTrackPlayNext(track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
@ -237,33 +245,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
|
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
|
||||||
scope.launch {
|
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
|
||||||
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>) {
|
fun bulkPlayNext(tracks: List<MetadataTrack>) {
|
||||||
scope.launch {
|
remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
|
||||||
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(
|
Scaffold(
|
||||||
|
|||||||
@ -70,6 +70,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationState
|
|||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost
|
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.lyrics.LyricsScreen
|
||||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
|
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
|
||||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
||||||
@ -118,6 +119,7 @@ fun AppShell(
|
|||||||
}
|
}
|
||||||
|
|
||||||
ConnectionRequestDialogHost()
|
ConnectionRequestDialogHost()
|
||||||
|
PlayDestinationPickerHost()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
val useSidebar = viewModel.useSidebar()
|
val useSidebar = viewModel.useSidebar()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user