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