From dd42bd51218dd478c86ad40ee620fbba30e2e9b0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 12:15:08 -0400 Subject: [PATCH] feat(android): PlaylistsRepository.appendTrack + AddToPlaylistSheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the playlist-append capability end-to-end. Repository does optimistic Room write at MAX(position)+1 + REST + MutationQueue fallback (PLAYLIST_APPEND kind from the previous commit). Sheet lists user-owned playlists for the menu's "Add to playlist…" item; not yet reachable from a user surface — TrackActionsSheet wires it in a later commit. Also adds maxPosition + insertOrIgnore to CachedPlaylistTrackDao to support the optimistic write. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cache/db/dao/CachedPlaylistTrackDao.kt | 11 ++ .../playlists/data/PlaylistsRepository.kt | 42 +++++++ .../track_actions/AddToPlaylistSheet.kt | 115 ++++++++++++++++++ .../track_actions/AddToPlaylistViewModel.kt | 53 ++++++++ 4 files changed, 221 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/AddToPlaylistSheet.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/AddToPlaylistViewModel.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistTrackDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistTrackDao.kt index f9fa32a6..02ff83b0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistTrackDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistTrackDao.kt @@ -24,6 +24,17 @@ interface CachedPlaylistTrackDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(rows: List) + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertOrIgnore(row: CachedPlaylistTrackEntity) + + /** + * Highest existing position for [playlistId], or null if the + * playlist has no rows. Used by appendTrack to assign the next + * position before the optimistic Room write. + */ + @Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId") + suspend fun maxPosition(playlistId: String): Int? + /** Replace-all pattern for a playlist; called after a sync delta lands. */ @Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId") suspend fun deleteByPlaylist(playlistId: String) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt index 94e89a60..0507a531 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt @@ -1,10 +1,12 @@ package com.fabledsword.minstrel.playlists.data +import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest import com.fabledsword.minstrel.api.endpoints.PlaylistsApi import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity +import com.fabledsword.minstrel.cache.mutations.MutationQueue import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistTrackRef import com.fabledsword.minstrel.models.wire.PlaylistDetailWire @@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import retrofit2.Retrofit import retrofit2.create +import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -35,6 +38,7 @@ import javax.inject.Singleton class PlaylistsRepository @Inject constructor( private val playlistDao: CachedPlaylistDao, private val playlistTrackDao: CachedPlaylistTrackDao, + private val mutationQueue: MutationQueue, retrofit: Retrofit, ) { private val api: PlaylistsApi = retrofit.create() @@ -94,8 +98,46 @@ class PlaylistsRepository @Inject constructor( tracks = wire.tracks.map { it.toDomain() }, ) } + + /** + * Append a single track to a user-owned playlist. Follows the + * project's offline-first write contract: optimistic Room insert + * first (so the playlist detail screen shows the new row instantly), + * then fire `POST /api/playlists/{id}/tracks`. On transport failure + * the call is enqueued via MutationQueue for the next drain pass. + * + * The optimistic row's position is best-effort — server reasserts + * canonical position on the next playlist fetch. The (playlistId, + * trackId) composite PK guarantees no duplicate rows; if the track + * is already in the playlist, the IGNORE on conflict leaves the + * existing position unchanged. + */ + suspend fun appendTrack(playlistId: String, trackId: String): AppendOutcome { + val nextPos = (playlistTrackDao.maxPosition(playlistId) ?: -1) + 1 + playlistTrackDao.insertOrIgnore( + CachedPlaylistTrackEntity( + playlistId = playlistId, + trackId = trackId, + position = nextPos, + ), + ) + return try { + api.appendTracks( + playlistId = playlistId, + body = AppendTracksRequest(trackIds = listOf(trackId)), + ) + AppendOutcome.Synced + } catch ( + @Suppress("SwallowedException") _: IOException, + ) { + mutationQueue.enqueuePlaylistAppend(playlistId, listOf(trackId)) + AppendOutcome.Queued + } + } } +enum class AppendOutcome { Synced, Queued } + /** * Snapshot of a playlist + its per-track display fields, as returned * by `PlaylistsRepository.refreshDetail`. UI uses this directly for diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/AddToPlaylistSheet.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/AddToPlaylistSheet.kt new file mode 100644 index 00000000..f5343e2a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/AddToPlaylistSheet.kt @@ -0,0 +1,115 @@ +package com.fabledsword.minstrel.shared.widgets.track_actions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composables.icons.lucide.ListMusic +import com.composables.icons.lucide.Lucide +import com.fabledsword.minstrel.models.PlaylistRef + +/** + * ModalBottomSheet that lists the caller's user-owned playlists and + * pops back with the picked id via [onPick]. Mirrors Flutter's + * `AddToPlaylistSheet`. System playlists are filtered out — they're + * not appendable. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddToPlaylistSheet( + onPick: (playlistId: String) -> Unit, + onDismiss: () -> Unit, + viewModel: AddToPlaylistViewModel = hiltViewModel(), +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val state by viewModel.uiState.collectAsStateWithLifecycle() + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { + Text( + text = "Add to playlist", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + when (val s = state) { + AddToPlaylistUiState.Loading -> Box( + modifier = Modifier.fillMaxWidth().padding(24.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } + is AddToPlaylistUiState.Error -> Text( + text = s.message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + is AddToPlaylistUiState.Success -> if (s.playlists.isEmpty()) { + Text( + text = "You haven't created any playlists yet.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + } else { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(items = s.playlists, key = { it.id }) { p -> + PlaylistPickRow(playlist = p, onClick = { onPick(p.id) }) + } + } + } + } + } + } +} + +@Composable +private fun PlaylistPickRow(playlist: PlaylistRef, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Lucide.ListMusic, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = playlist.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${playlist.trackCount} ${if (playlist.trackCount == 1) "track" else "tracks"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/AddToPlaylistViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/AddToPlaylistViewModel.kt new file mode 100644 index 00000000..643684f0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/track_actions/AddToPlaylistViewModel.kt @@ -0,0 +1,53 @@ +package com.fabledsword.minstrel.shared.widgets.track_actions + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.fabledsword.minstrel.models.PlaylistRef +import com.fabledsword.minstrel.playlists.data.PlaylistsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + +sealed interface AddToPlaylistUiState { + data object Loading : AddToPlaylistUiState + data class Success(val playlists: List) : AddToPlaylistUiState + data class Error(val message: String) : AddToPlaylistUiState +} + +/** + * Backs [AddToPlaylistSheet] with the caller's user-owned playlists. + * System playlists (For You / Discover / etc.) are filtered out at + * the repository layer — they're not appendable. + */ +@HiltViewModel +class AddToPlaylistViewModel @Inject constructor( + repository: PlaylistsRepository, +) : ViewModel() { + + val uiState: StateFlow = repository + .observeUserPlaylists() + .map { list -> AddToPlaylistUiState.Success(list) as AddToPlaylistUiState } + .onStart { + // Best-effort refresh so the sheet reflects newly-created + // playlists from another device. Cached list shows first; + // refresh emission updates the state once Room reflects it. + viewModelScope.launch { + runCatching { repository.refreshList() } + } + emit(AddToPlaylistUiState.Loading) + } + .catch { e -> emit(AddToPlaylistUiState.Error(e.message ?: "Failed to load playlists")) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = AddToPlaylistUiState.Loading, + ) +}