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:
+11
@@ -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)
|
||||
|
||||
+42
@@ -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
|
||||
|
||||
+115
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user