feat(android): PlaylistsRepository.appendTrack + AddToPlaylistSheet

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:15:08 -04:00
parent 1f4a5e08bd
commit dd42bd5121
4 changed files with 221 additions and 0 deletions
@@ -24,6 +24,17 @@ interface CachedPlaylistTrackDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedPlaylistTrackEntity>)
@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)
@@ -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
@@ -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,
)
}
}
}
@@ -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<PlaylistRef>) : 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<AddToPlaylistUiState> = 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,
)
}