diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index ad22befc..88238e52 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -57,16 +57,23 @@ import com.fabledsword.minstrel.models.PlaylistTrackRef import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.nav.PlaylistDetail import com.fabledsword.minstrel.player.PlayerController +import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.playlists.data.PlaylistDetailRef import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + // ─── State ─────────────────────────────────────────────────────────── sealed interface PlaylistDetailUiState { @@ -80,6 +87,7 @@ sealed interface PlaylistDetailUiState { @HiltViewModel class PlaylistDetailViewModel @Inject constructor( private val repository: PlaylistsRepository, + private val likes: LikesRepository, private val player: PlayerController, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -90,10 +98,26 @@ class PlaylistDetailViewModel @Inject constructor( private val internal = MutableStateFlow(PlaylistDetailUiState.Loading) val uiState: StateFlow = internal.asStateFlow() + val likedTrackIds: StateFlow> = + likes.observeLikedTracks() + .map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = emptySet(), + ) + init { refresh() } + fun toggleLikeTrack(trackId: String) { + val desired = trackId !in likedTrackIds.value + viewModelScope.launch { + likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired) + } + } + fun refresh() { viewModelScope.launch { try { @@ -146,14 +170,21 @@ fun PlaylistDetailScreen( when (val s = state) { PlaylistDetailUiState.Loading -> LoadingCentered() is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh) - is PlaylistDetailUiState.Success -> PlaylistDetailBody( - detail = s.detail, - onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) }, - onShuffleAll = { - viewModel.play(s.detail.tracks.shuffled(), startTrackId = null) - }, - onTrackClick = { id -> viewModel.play(s.detail.tracks, startTrackId = id) }, - ) + is PlaylistDetailUiState.Success -> { + val likedTrackIds by viewModel.likedTrackIds.collectAsState() + PlaylistDetailBody( + detail = s.detail, + likedTrackIds = likedTrackIds, + onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) }, + onShuffleAll = { + viewModel.play(s.detail.tracks.shuffled(), startTrackId = null) + }, + onTrackClick = { id -> + viewModel.play(s.detail.tracks, startTrackId = id) + }, + onToggleTrackLike = viewModel::toggleLikeTrack, + ) + } } } } @@ -169,9 +200,11 @@ private fun currentTitle(state: PlaylistDetailUiState): String = when (state) { @Composable private fun PlaylistDetailBody( detail: PlaylistDetailRef, + likedTrackIds: Set, onPlayAll: () -> Unit, onShuffleAll: () -> Unit, onTrackClick: (String) -> Unit, + onToggleTrackLike: (String) -> Unit, ) { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -180,7 +213,12 @@ private fun PlaylistDetailBody( item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll) } item { HorizontalDivider() } items(items = detail.tracks, key = { it.position }) { row -> - TrackRow(row = row, onClick = { row.trackId?.let(onTrackClick) }) + TrackRow( + row = row, + liked = row.trackId != null && row.trackId in likedTrackIds, + onClick = { row.trackId?.let(onTrackClick) }, + onToggleLike = { row.trackId?.let(onToggleTrackLike) }, + ) HorizontalDivider() } if (detail.tracks.isEmpty()) { @@ -283,14 +321,19 @@ private fun PlaylistCover(playlist: PlaylistRef) { } @Composable -private fun TrackRow(row: PlaylistTrackRef, onClick: () -> Unit) { +private fun TrackRow( + row: PlaylistTrackRef, + liked: Boolean, + onClick: () -> Unit, + onToggleLike: () -> Unit, +) { val enabled = row.isAvailable val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA Row( modifier = Modifier .fillMaxWidth() .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 16.dp, vertical = 10.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -321,6 +364,9 @@ private fun TrackRow(row: PlaylistTrackRef, onClick: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = rowAlpha), modifier = Modifier.padding(start = 12.dp), ) + if (enabled) { + LikeButton(liked = liked, onToggle = onToggleLike) + } } }