refactor: make sure screens use viewmodels instead of having logic in composables

This commit is contained in:
Kingkor Roy Tirtho 2026-08-14 17:11:38 +06:00
parent 16e2a743a5
commit f4eac7dbd9
11 changed files with 386 additions and 356 deletions

View File

@ -131,6 +131,8 @@ val sharedModules = module {
playbackHelper = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
)
}
@ -142,6 +144,9 @@ val sharedModules = module {
playbackHelper = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
libraryRepository = get(),
shareService = get(),
downloadManager = get(),
)
}
@ -155,6 +160,8 @@ val sharedModules = module {
savedTracksRepository = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
)
}
@ -173,6 +180,8 @@ val sharedModules = module {
libraryRepository = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
)
}

View File

@ -109,9 +109,6 @@ val navigationModule = module {
),
audioPlayerQueue = get(),
audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(),
)
}
@ -123,9 +120,6 @@ val navigationModule = module {
),
audioPlayerQueue = get(),
audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(),
)
}
@ -138,9 +132,6 @@ val navigationModule = module {
),
audioPlayerQueue = get(),
audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(),
)
}
@ -155,9 +146,6 @@ val navigationModule = module {
),
audioPlayerQueue = get(),
audioPlayer = get(),
shareService = get(),
downloadsViewModel = get(),
libraryRepository = get(),
navigationCommands = get(),
)
}

View File

@ -74,6 +74,23 @@ data class TrackOptionsState(
val isBlacklisted: Boolean = false,
)
data class TrackOptionsContext(
val currentTrackId: String? = null,
val queueTrackIds: Set<String> = emptySet(),
val savedTrackIds: Set<String> = emptySet(),
val blacklistedTrackIds: Set<String> = emptySet(),
val blacklistedArtistIds: Set<String> = emptySet(),
val forceFavorite: Boolean = false,
) {
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
isInQueue = track.id in queueTrackIds,
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = forceFavorite || track.id in savedTrackIds,
isBlacklisted = track.id in blacklistedTrackIds ||
track.artists.any { it.id in blacklistedArtistIds },
)
}
@Composable
fun TrackOptions(
track: MetadataTrack,

View File

@ -18,33 +18,15 @@
package dev.krtirtho.spotube.modules.album
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState
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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun AlbumScreen(
@ -52,59 +34,15 @@ fun AlbumScreen(
viewModel: AlbumViewModel,
audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands
) {
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet()
return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
)
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
val dataState = state as? AlbumScreenState.Data
val album = dataState?.album
@ -142,40 +80,24 @@ fun AlbumScreen(
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is AlbumScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
currentTrackId = trackOptionsContext.currentTrackId,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playAlbumFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState,
onBulkDownload = { tracks ->
downloadsViewModel.downloadTracks(tracks)
},
onBulkAddToQueue = { tracks ->
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor,
onBulkDownload = viewModel::downloadTracks,
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = viewModel::addTracksToPlaylist,
)
},
)

View File

@ -26,19 +26,25 @@ 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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -89,6 +95,8 @@ class AlbumViewModel(
private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<AlbumViewModel>()
@ -97,12 +105,37 @@ class AlbumViewModel(
val savedAlbumIds
get() = libraryRepository.savedAlbumIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow,
_blacklistedTrackIds,
_blacklistedArtistIds,
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
TrackOptionsContext(
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet(),
savedTrackIds = savedIds,
blacklistedTrackIds = blacklistedTracks,
blacklistedArtistIds = blacklistedArtists,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
init {
viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin
@ -111,6 +144,7 @@ class AlbumViewModel(
.distinctUntilChanged()
.collect {
loadInitialData()
loadCurrentUser()
}
}
blacklistRepository.blacklistedTracks
@ -121,6 +155,14 @@ class AlbumViewModel(
.launchIn(viewModelScope)
}
private suspend fun loadCurrentUser() {
runCatching {
_currentUserId.value = libraryRepository.currentUser()?.id
}.onFailure { e ->
logger.e(e) { "Failed to load current user" }
}
}
private suspend fun loadInitialData() = runCatching {
_state.value = AlbumScreenState.Loading
val albumInfo = repository.getAlbumInfo(albumId)
@ -235,14 +277,50 @@ class AlbumViewModel(
}
}
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
is TrackOptionsAction.Share -> {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
}
}
}
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()

View File

@ -45,10 +45,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -63,14 +59,12 @@ import coil3.compose.AsyncImage
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState
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.share.ShareService
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
@ -80,13 +74,9 @@ import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.ArtistCard
import dev.krtirtho.spotube.core.ui.component.PlaylistCard
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
@ -95,7 +85,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@Composable
@ -103,58 +92,16 @@ fun ArtistScreen(
viewModel: ArtistViewModel,
audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands
) {
val scope = rememberCoroutineScope()
val state by viewModel.state.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet()
return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
)
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
Scaffold(
topBar = { ApplicationMainBar() }
@ -209,8 +156,8 @@ fun ArtistScreen(
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playTopTracksFromTrack,
onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState,
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor,
onArtistClick = { trackArtist ->
navigationCommands.navigateTo(
Routes.Artist(
@ -228,13 +175,10 @@ fun ArtistScreen(
onLoadNextPage = {},
simplified = true,
scrollable = false,
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) },
onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) },
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
onBulkDownload = viewModel::downloadTracks,
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
)
}
@ -292,15 +236,8 @@ fun ArtistScreen(
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = viewModel::addTracksToPlaylist,
)
}
}

View File

@ -27,15 +27,21 @@ 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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -65,6 +71,8 @@ class ArtistViewModel(
private val savedTracksRepository: SavedTracksRepository,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<ArtistViewModel>()
@ -77,12 +85,37 @@ class ArtistViewModel(
val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow,
_blacklistedTrackIds,
_blacklistedArtistIds,
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
TrackOptionsContext(
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet(),
savedTrackIds = savedIds,
blacklistedTrackIds = blacklistedTracks,
blacklistedArtistIds = blacklistedArtists,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
init {
viewModelScope.launch {
libraryRepository.savedArtistIdsFlow.collect {
@ -98,9 +131,20 @@ class ArtistViewModel(
blacklistRepository.blacklistedArtists
.onEach { artists -> _blacklistedArtistIds.value = artists.map { it.id }.toSet() }
.launchIn(viewModelScope)
loadCurrentUser()
loadOverview()
}
private fun loadCurrentUser() {
viewModelScope.launch {
runCatching {
_currentUserId.value = libraryRepository.currentUser()?.id
}.onFailure { e ->
logger.e(e) { "Failed to load current user" }
}
}
}
fun refresh() {
repository.invalidateCaches()
loadOverview()
@ -319,14 +363,50 @@ class ArtistViewModel(
savedTracksRepository.saveTracks(listOf(track.id))
}
}
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
is TrackOptionsAction.Share -> {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
}
}
}
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
}
private fun loadOverview() {
_state.value = ArtistScreenState.Loading
viewModelScope.launch {

View File

@ -27,35 +27,22 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState
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.share.ShareService
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData
import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun PlaylistScreen(
@ -63,40 +50,17 @@ fun PlaylistScreen(
viewModel: PlaylistViewModel,
audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands
) {
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
var showEditPlaylist by remember { mutableStateOf(false) }
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var showAddTracksDialog by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
val dataState = state as? PlaylistScreenState.Data
val playlist = dataState?.playlist
@ -108,23 +72,6 @@ fun PlaylistScreen(
val isPlaying = currentCollectionEntry?.id == playlistId &&
playerState == PlayerState.PLAYING
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet()
return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = savedTrackIds.contains(track.id),
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
)
}
val errorMessage = (state as? PlaylistScreenState.Error)?.message
val isLoading = state is PlaylistScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
@ -168,27 +115,18 @@ fun PlaylistScreen(
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is PlaylistScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
currentTrackId = trackOptionsContext.currentTrackId,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playPlaylistFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState,
onBulkDownload = { tracks ->
downloadsViewModel.downloadTracks(tracks)
},
onBulkAddToQueue = { tracks ->
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor,
onBulkDownload = viewModel::downloadTracks,
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
footerContent = footerContent,
trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
@ -219,15 +157,8 @@ fun PlaylistScreen(
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { selectedPlaylistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
selectedPlaylistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = viewModel::addTracksToPlaylist,
)
AddTracksToPlaylistDialog(

View File

@ -26,20 +26,26 @@ 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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -90,6 +96,8 @@ class PlaylistViewModel(
private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<PlaylistViewModel>()
@ -107,6 +115,28 @@ class PlaylistViewModel(
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow,
_blacklistedTrackIds,
_blacklistedArtistIds,
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
TrackOptionsContext(
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet(),
savedTrackIds = savedIds,
blacklistedTrackIds = blacklistedTracks,
blacklistedArtistIds = blacklistedArtists,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
init {
viewModelScope.launch {
repository.pluginManager.selectedMetadataPlugin
@ -280,14 +310,50 @@ class PlaylistViewModel(
}
}
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
is TrackOptionsAction.Share -> {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
}
}
}
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
}
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = _blacklistedTrackIds.value
val artistIds = _blacklistedArtistIds.value

View File

@ -18,34 +18,16 @@
package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.liked_tracks
@ -54,56 +36,14 @@ fun SavedTracksScreen(
viewModel: SavedTracksViewModel,
audioPlayerQueue: AudioPlayerQueue,
audioPlayer: AudioPlayerInterface,
shareService: ShareService,
downloadsViewModel: DownloadsViewModel,
libraryRepository: LibraryRepository,
navigationCommands: NavigationCommands
) {
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val blacklistedTrackIds by viewModel.blacklistedTrackIds.collectAsStateWithLifecycle()
val blacklistedArtistIds by viewModel.blacklistedArtistIds.collectAsStateWithLifecycle()
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
val queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet()
return TrackOptionsState(
isInQueue = queueTrackIds.contains(track.id),
isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = true,
isBlacklisted = track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds },
)
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
if (action is TrackOptionsAction.Download) {
downloadsViewModel.downloadTrack(track)
}
if (action is TrackOptionsAction.AddToPlaylist) {
tracksToAddToPlaylist = listOf(track)
showAddToPlaylistPicker = true
}
}
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
val dataState = state as? SavedTracksScreenState.Data
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
@ -133,40 +73,24 @@ fun SavedTracksScreen(
tracks = dataState?.tracks ?: emptyList(),
hasMore = dataState?.nextPagination != null,
isLoadingNextPage = state is SavedTracksScreenState.Data.LoadingMore,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
currentTrackId = trackOptionsContext.currentTrackId,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playSavedTracksFromTrack,
onLoadNextPage = viewModel::loadNextTracksPage,
onArtistClick = { navigationCommands.navigateTo(Routes.Artist(it.id)) },
onAlbumClick = { navigationCommands.navigateTo(Routes.Album(it.id)) },
onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState,
onBulkDownload = { tracks ->
downloadsViewModel.downloadTracks(tracks)
},
onBulkAddToQueue = { tracks ->
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
onBulkAddToPlaylist = { tracks ->
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
onTrackOptionsAction = viewModel::handleTrackOptionsAction,
trackOptionsState = trackOptionsContext::stateFor,
onBulkDownload = viewModel::downloadTracks,
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = { showAddToPlaylistPicker = false },
onPlaylistSelected = { playlistId ->
scope.launch {
libraryRepository.addTracksToPlaylist(
playlistId,
tracksToAddToPlaylist.map { it.id }
)
}
},
onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = viewModel::addTracksToPlaylist,
)
},
)

View File

@ -34,10 +34,15 @@ 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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.library.LibraryRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
@ -46,6 +51,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent
@ -88,6 +94,9 @@ class SavedTracksViewModel(
private val playbackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
private val libraryRepository: LibraryRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<SavedTracksViewModel>()
@ -96,12 +105,36 @@ class SavedTracksViewModel(
val savedTrackIdsFlow: StateFlow<Set<String>>
get() = repository.savedTracksIdsFlow
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private val _blacklistedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedTrackIds: StateFlow<Set<String>> = _blacklistedTrackIds.asStateFlow()
private val _blacklistedArtistIds = MutableStateFlow<Set<String>>(emptySet())
val blacklistedArtistIds: StateFlow<Set<String>> = _blacklistedArtistIds.asStateFlow()
private val _tracksToAddToPlaylist = MutableStateFlow<List<MetadataTrack>>(emptyList())
private val _showAddToPlaylistPicker = MutableStateFlow(false)
val showAddToPlaylistPicker: StateFlow<Boolean> = _showAddToPlaylistPicker.asStateFlow()
val trackOptionsContext: StateFlow<TrackOptionsContext> = combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
_blacklistedTrackIds,
_blacklistedArtistIds,
) { queue, currentEntry, blacklistedTracks, blacklistedArtists ->
TrackOptionsContext(
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
queueTrackIds = queue.mapNotNull { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id
}.toSet(),
blacklistedTrackIds = blacklistedTracks,
blacklistedArtistIds = blacklistedArtists,
forceFavorite = true,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext(forceFavorite = true))
init {
viewModelScope.launch {
combine(
@ -112,6 +145,7 @@ class SavedTracksViewModel(
repository.savedTracksIdsFlow
) { _, _ -> }.collect {
loadInitialData()
loadCurrentUser()
}
}
blacklistRepository.blacklistedTracks
@ -122,6 +156,14 @@ class SavedTracksViewModel(
.launchIn(viewModelScope)
}
private suspend fun loadCurrentUser() {
runCatching {
_currentUserId.value = libraryRepository.currentUser()?.id
}.onFailure { e ->
logger.e(e) { "Failed to load current user" }
}
}
private suspend fun loadInitialData() {
runCatching {
val tracksResult = repository.getSavedTracks()
@ -210,14 +252,50 @@ class SavedTracksViewModel(
}
is TrackOptionsAction.ToggleFavorite -> {}
is TrackOptionsAction.Download -> {}
is TrackOptionsAction.Download -> downloadManager.enqueue(track)
is TrackOptionsAction.ToggleBlacklist -> toggleTrackBlacklist(track)
is TrackOptionsAction.Share -> {}
is TrackOptionsAction.AddToPlaylist -> {}
is TrackOptionsAction.Share -> {
val uri = track.externalUri?.takeIf { it.isNotBlank() }
if (uri != null) {
shareService.share(uri, track.title)
}
}
is TrackOptionsAction.AddToPlaylist -> {
_tracksToAddToPlaylist.value = listOf(track)
_showAddToPlaylistPicker.value = true
}
}
}
}
fun addTracksToPlaylist(playlistId: String) {
viewModelScope.launch {
runCatching {
libraryRepository.addTracksToPlaylist(
playlistId,
_tracksToAddToPlaylist.value.map { it.id },
)
}.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess {
_showAddToPlaylistPicker.value = false
}
}
}
fun showAddToPlaylistPicker(tracks: List<MetadataTrack>) {
_tracksToAddToPlaylist.value = tracks
_showAddToPlaylistPicker.value = true
}
fun dismissAddToPlaylistPicker() {
_showAddToPlaylistPicker.value = false
}
fun downloadTracks(tracks: List<MetadataTrack>) {
tracks.forEach { track -> downloadManager.enqueue(track) }
}
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = _blacklistedTrackIds.value
val artistIds = _blacklistedArtistIds.value