diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index a067476b..4517e63b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -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(), ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index 5d4c8054..8d059071 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -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(), ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index 05a31564..c401e631 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -74,6 +74,23 @@ data class TrackOptionsState( val isBlacklisted: Boolean = false, ) +data class TrackOptionsContext( + val currentTrackId: String? = null, + val queueTrackIds: Set = emptySet(), + val savedTrackIds: Set = emptySet(), + val blacklistedTrackIds: Set = emptySet(), + val blacklistedArtistIds: Set = 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, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index 8895dae7..8a91a2c1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -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>(emptyList()) } - var currentUserId by remember { mutableStateOf(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, ) }, ) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index 23ac0683..35f2b9ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -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() @@ -97,12 +105,37 @@ class AlbumViewModel( val savedAlbumIds get() = libraryRepository.savedAlbumIdsFlow + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId.asStateFlow() + private val _blacklistedTrackIds = MutableStateFlow>(emptySet()) val blacklistedTrackIds: StateFlow> = _blacklistedTrackIds.asStateFlow() private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) + private val _showAddToPlaylistPicker = MutableStateFlow(false) + val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() + + val trackOptionsContext: StateFlow = 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) { + _tracksToAddToPlaylist.value = tracks + _showAddToPlaylistPicker.value = true + } + + fun dismissAddToPlaylistPicker() { + _showAddToPlaylistPicker.value = false + } + + fun downloadTracks(tracks: List) { + tracks.forEach { track -> downloadManager.enqueue(track) } + } + fun addTracksToQueue(tracks: List) { viewModelScope.launch { val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 0a024f95..5b76c144 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -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>(emptyList()) } - var currentUserId by remember { mutableStateOf(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, ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index f9d2f81a..ed078be4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -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() @@ -77,12 +85,37 @@ class ArtistViewModel( val savedTrackIds get() = savedTracksRepository.savedTracksIdsFlow + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId.asStateFlow() + private val _blacklistedTrackIds = MutableStateFlow>(emptySet()) val blacklistedTrackIds: StateFlow> = _blacklistedTrackIds.asStateFlow() private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) + private val _showAddToPlaylistPicker = MutableStateFlow(false) + val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() + + val trackOptionsContext: StateFlow = 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) { + _tracksToAddToPlaylist.value = tracks + _showAddToPlaylistPicker.value = true + } + + fun dismissAddToPlaylistPicker() { + _showAddToPlaylistPicker.value = false + } + + fun downloadTracks(tracks: List) { + tracks.forEach { track -> downloadManager.enqueue(track) } + } + private fun loadOverview() { _state.value = ArtistScreenState.Loading viewModelScope.launch { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index a80cb5b8..7a5c0d58 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -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>(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( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index 0567a1f7..8d244033 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -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() @@ -107,6 +115,28 @@ class PlaylistViewModel( private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) + private val _showAddToPlaylistPicker = MutableStateFlow(false) + val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() + + val trackOptionsContext: StateFlow = 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) { + _tracksToAddToPlaylist.value = tracks + _showAddToPlaylistPicker.value = true + } + + fun dismissAddToPlaylistPicker() { + _showAddToPlaylistPicker.value = false + } + + fun downloadTracks(tracks: List) { + tracks.forEach { track -> downloadManager.enqueue(track) } + } + fun isTrackBlacklisted(track: MetadataTrack): Boolean { val trackIds = _blacklistedTrackIds.value val artistIds = _blacklistedArtistIds.value diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index bf6fa1d5..045a0380 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -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>(emptyList()) } - var currentUserId by remember { mutableStateOf(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, ) }, ) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 0a203772..925bd50a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -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() @@ -96,12 +105,36 @@ class SavedTracksViewModel( val savedTrackIdsFlow: StateFlow> get() = repository.savedTracksIdsFlow + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId.asStateFlow() + private val _blacklistedTrackIds = MutableStateFlow>(emptySet()) val blacklistedTrackIds: StateFlow> = _blacklistedTrackIds.asStateFlow() private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) + private val _showAddToPlaylistPicker = MutableStateFlow(false) + val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() + + val trackOptionsContext: StateFlow = 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) { + _tracksToAddToPlaylist.value = tracks + _showAddToPlaylistPicker.value = true + } + + fun dismissAddToPlaylistPicker() { + _showAddToPlaylistPicker.value = false + } + + fun downloadTracks(tracks: List) { + tracks.forEach { track -> downloadManager.enqueue(track) } + } + fun isTrackBlacklisted(track: MetadataTrack): Boolean { val trackIds = _blacklistedTrackIds.value val artistIds = _blacklistedArtistIds.value