feat(android): Phase 17a — PlaylistDetail per-track LikeButton
Closes the per-track-likes gap on PlaylistDetail (Album + Artist
details already had it via Phase 14). Same VM-owned-state pattern:
LikesRepository observeLikedTracks → mutableSet<String> Flow,
toggleLikeTrack via the optimistic-write + MutationQueue path.
Modified:
- playlists/ui/PlaylistDetailScreen.kt — VM gets LikesRepository
injection + `likedTrackIds: StateFlow<Set<String>>` +
`toggleLikeTrack(trackId)`. PlaylistDetailBody threads the set
+ onToggleTrackLike down to each row. TrackRow renders LikeButton
only when the upstream track is still available (greyed-out
rows for removed tracks omit the heart entirely — can't like
something that no longer exists).
Row vertical padding tightened 10dp → 8dp to match the album
track-row sizing now that the heart icon is present.
Cross-restart user persistence is the next commit within this phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+57
-11
@@ -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>(PlaylistDetailUiState.Loading)
|
||||
val uiState: StateFlow<PlaylistDetailUiState> = internal.asStateFlow()
|
||||
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
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<String>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user