Phase 6: Playlists, local-first with server sync

Adds user-created playlists synced to/from Navidrome, using the same
Room-plus-WorkManager outbox pattern Phase 5 established for downloads:
every read/write goes through Room first (fully offline-capable), and
mutations mark a PENDING_CREATE/UPDATE/DELETE syncState that
PlaylistSyncWorker later pushes to the server, then pulls the server's
current playlists back down to reconcile.

New Subsonic endpoints: getPlaylists, getPlaylist, createPlaylist,
updatePlaylist, deletePlaylist. Track-list updates are computed as an
add/remove diff against a freshly-fetched remote track list
(computePlaylistSyncDelta, unit tested in isolation since a wrong
index here deletes the wrong track server-side, not just a local
glitch) rather than replaying individual UI actions - simpler, and
self-correcting if a previous push partially failed. Conflict
handling is last-write-wins at the whole-playlist level: pushPending()
always runs before pullRemote(), so a playlist with local changes
queued keeps them as the source of truth for that sync pass.

UI: the bottom-nav Library tab now switches between Playlists and
Artists (Spotify's own convention), a playlist detail screen
(play/download-all/remove-track/delete), and an "add to playlist"
entry point wired into AlbumDetailScreen's track rows - a bottom sheet
listing existing playlists plus inline playlist creation.

Verified on-device against the real Navidrome server: creating a
playlist assigns a real server id and reaches SYNCED; adding and
removing a track push the expected updatePlaylist add/remove diff
(confirmed via the actual HTTP requests) and both directions
reconcile correctly; deleting a playlist issues a real
server-side deletePlaylist; creating a playlist entirely offline
stays queued (WorkManager correctly blocks the sync job on the
CONNECTIVITY constraint, confirmed via dumpsys jobscheduler) and
syncs automatically the moment connectivity returns, with no crash
and no user action needed.

Bumped DeepwaveDatabase to version 2 for the new playlist tables,
using the same fallbackToDestructiveMigration already in place since
Phase 5 - this drops and recreates the whole database, including
Phase 5's downloaded_tracks table, so previously-downloaded tracks'
files remain on disk but drop out of the app's tracking until
re-downloaded. Expected given the documented "schema isn't stable
yet" tradeoff, not a regression, but worth knowing before installing
this build over an existing one with real downloads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
christopher
2026-09-17 14:59:17 -04:00
co-authored by Claude Sonnet 5
parent a89e81983e
commit 4c116eb84f
29 changed files with 1510 additions and 5 deletions
@@ -10,6 +10,7 @@ import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.InfernalAquatics.deepwave.di.DownloadRepositoryEntryPoint
import com.InfernalAquatics.deepwave.di.OkHttpClientEntryPoint
import com.InfernalAquatics.deepwave.di.PlaylistRepositoryEntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@@ -33,6 +34,11 @@ class DeepwaveApplication : Application(), SingletonImageLoader.Factory {
// downloads into LocalTrackFiles, which needs to be running before playback ever needs
// to resolve a track to a local file, not just whenever the Downloads screen opens first.
EntryPoints.get(this, DownloadRepositoryEntryPoint::class.java).downloadRepository()
// Same reasoning as above: PlaylistRepository's init block schedules the periodic sync
// worker and starts watching login state, neither of which should wait for the
// Playlists tab to be opened first.
EntryPoints.get(this, PlaylistRepositoryEntryPoint::class.java).playlistRepository()
}
/**
@@ -4,9 +4,17 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import com.InfernalAquatics.deepwave.data.local.download.DownloadDao
import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistDao
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistEntity
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistTrackEntity
/** Single shared database - Phase 6's playlist tables join this same instance rather than a second DB. */
@Database(entities = [DownloadedTrackEntity::class], version = 1, exportSchema = false)
/** Single shared database - downloads (Phase 5) and playlists (Phase 6) join this same instance. */
@Database(
entities = [DownloadedTrackEntity::class, PlaylistEntity::class, PlaylistTrackEntity::class],
version = 2,
exportSchema = false,
)
abstract class DeepwaveDatabase : RoomDatabase() {
abstract fun downloadDao(): DownloadDao
abstract fun playlistDao(): PlaylistDao
}
@@ -0,0 +1,68 @@
package com.InfernalAquatics.deepwave.data.local.playlist
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
data class PlaylistWithCount(
@Embedded val entity: PlaylistEntity,
val trackCount: Int,
)
@Dao
interface PlaylistDao {
@Query(
"""
SELECT playlists.*, COUNT(playlist_tracks.trackId) AS trackCount
FROM playlists
LEFT JOIN playlist_tracks ON playlist_tracks.playlistLocalId = playlists.localId
WHERE playlists.syncState != 'PENDING_DELETE'
GROUP BY playlists.localId
ORDER BY playlists.updatedAt DESC
""",
)
fun observePlaylistsWithCount(): Flow<List<PlaylistWithCount>>
@Query("SELECT * FROM playlists WHERE localId = :localId")
fun observePlaylist(localId: String): Flow<PlaylistEntity?>
@Query("SELECT * FROM playlists WHERE localId = :localId")
suspend fun getPlaylist(localId: String): PlaylistEntity?
@Query("SELECT * FROM playlists WHERE serverId = :serverId")
suspend fun getPlaylistByServerId(serverId: String): PlaylistEntity?
@Query("SELECT * FROM playlists WHERE syncState != 'SYNCED'")
suspend fun getPendingPlaylists(): List<PlaylistEntity>
@Query("SELECT * FROM playlists WHERE syncState = 'SYNCED'")
suspend fun getSyncedPlaylists(): List<PlaylistEntity>
@Upsert
suspend fun upsertPlaylist(entity: PlaylistEntity)
@Query("DELETE FROM playlists WHERE localId = :localId")
suspend fun deletePlaylistRow(localId: String)
@Query("SELECT * FROM playlist_tracks WHERE playlistLocalId = :localId ORDER BY position ASC")
fun observeTracks(localId: String): Flow<List<PlaylistTrackEntity>>
@Query("SELECT * FROM playlist_tracks WHERE playlistLocalId = :localId ORDER BY position ASC")
suspend fun getTracks(localId: String): List<PlaylistTrackEntity>
@Insert
suspend fun insertTracks(tracks: List<PlaylistTrackEntity>)
@Query("DELETE FROM playlist_tracks WHERE playlistLocalId = :localId")
suspend fun clearTracks(localId: String)
@Transaction
suspend fun replaceTracks(localId: String, tracks: List<PlaylistTrackEntity>) {
clearTracks(localId)
insertTracks(tracks)
}
}
@@ -0,0 +1,28 @@
package com.InfernalAquatics.deepwave.data.local.playlist
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* SYNCED: matches the server. PENDING_*: a local mutation is still queued for
* PlaylistSyncWorker to push. Subsonic playlists carry no version/revision field to diff
* against, so conflicts are resolved last-write-wins, favoring whichever side (local or
* server) PlaylistSyncWorker's pull pass sees last - see PlaylistSyncWorker's doc comment.
*/
enum class PlaylistSyncState { SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE }
/**
* [localId] is a client-generated id, stable from the moment a playlist is created - the app
* (routes, UI state) always addresses playlists by this, never by [serverId], since a playlist
* created offline has no server id yet. [serverId] is null until PENDING_CREATE syncs.
*/
@Entity(tableName = "playlists")
data class PlaylistEntity(
@PrimaryKey val localId: String,
val serverId: String?,
val name: String,
val coverArtId: String?,
val createdAt: Long,
val updatedAt: Long,
val syncState: PlaylistSyncState,
)
@@ -0,0 +1,22 @@
package com.InfernalAquatics.deepwave.data.local.playlist
import androidx.room.Entity
/**
* One track in one playlist, at [position]. Carries enough song metadata to render and play the
* playlist entirely offline (same self-contained-metadata approach as DownloadedTrackEntity),
* rather than joining back to a separately-cached song table that doesn't exist in this app.
*/
@Entity(tableName = "playlist_tracks", primaryKeys = ["playlistLocalId", "position"])
data class PlaylistTrackEntity(
val playlistLocalId: String,
val position: Int,
val trackId: String,
val title: String,
val artistName: String?,
val albumName: String?,
val albumId: String?,
val artistId: String?,
val coverArtId: String?,
val durationSeconds: Int?,
)
@@ -30,3 +30,12 @@ data class Song(
val track: Int?,
val durationSeconds: Int?,
)
/** Local-first: [isSynced] is false while a create/edit/delete is still queued for the server. */
data class Playlist(
val id: String,
val name: String,
val coverArtId: String?,
val trackCount: Int,
val isSynced: Boolean,
)
@@ -4,7 +4,10 @@ import com.InfernalAquatics.deepwave.data.network.model.GetAlbumListResponse
import com.InfernalAquatics.deepwave.data.network.model.GetAlbumResponse
import com.InfernalAquatics.deepwave.data.network.model.GetArtistResponse
import com.InfernalAquatics.deepwave.data.network.model.GetArtistsResponse
import com.InfernalAquatics.deepwave.data.network.model.GetPlaylistResponse
import com.InfernalAquatics.deepwave.data.network.model.GetPlaylistsResponse
import com.InfernalAquatics.deepwave.data.network.model.PingResponse
import com.InfernalAquatics.deepwave.data.network.model.PlaylistResponse
import com.InfernalAquatics.deepwave.data.network.model.SearchResult3Response
import retrofit2.http.GET
import retrofit2.http.Query
@@ -53,4 +56,33 @@ interface SubsonicApi {
@Query("id") id: String,
@Query("submission") submission: Boolean = true,
): PingResponse
@GET("rest/getPlaylists")
suspend fun getPlaylists(): GetPlaylistsResponse
@GET("rest/getPlaylist")
suspend fun getPlaylist(@Query("id") id: String): GetPlaylistResponse
@GET("rest/createPlaylist")
suspend fun createPlaylist(
@Query("name") name: String,
@Query("songId") songIds: List<String> = emptyList(),
): PlaylistResponse
/**
* [songIndexToRemove] are indices into the playlist's CURRENT server-side track order -
* Navidrome applies removals against that original order regardless of what's also being
* added in the same call, so a diff computed against a freshly-fetched remote track list
* (see PlaylistSyncWorker) stays correct even when add and remove happen together.
*/
@GET("rest/updatePlaylist")
suspend fun updatePlaylist(
@Query("playlistId") playlistId: String,
@Query("name") name: String? = null,
@Query("songIdToAdd") songIdToAdd: List<String> = emptyList(),
@Query("songIndexToRemove") songIndexToRemove: List<Int> = emptyList(),
): PingResponse
@GET("rest/deletePlaylist")
suspend fun deletePlaylist(@Query("id") id: String): PingResponse
}
@@ -0,0 +1,66 @@
package com.InfernalAquatics.deepwave.data.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PlaylistSummaryDto(
val id: String,
val name: String,
val comment: String? = null,
val public: Boolean = false,
val songCount: Int = 0,
val duration: Int = 0,
val coverArt: String? = null,
)
@Serializable
data class PlaylistDetailDto(
val id: String,
val name: String,
val comment: String? = null,
val public: Boolean = false,
val songCount: Int = 0,
val duration: Int = 0,
val coverArt: String? = null,
val entry: List<SongDto> = emptyList(),
)
@Serializable
data class GetPlaylistsResponse(
@SerialName("subsonic-response") val subsonicResponse: GetPlaylistsBody,
)
@Serializable
data class GetPlaylistsBody(
val playlists: PlaylistsDto? = null,
val error: SubsonicError? = null,
)
@Serializable
data class PlaylistsDto(
val playlist: List<PlaylistSummaryDto> = emptyList(),
)
@Serializable
data class GetPlaylistResponse(
@SerialName("subsonic-response") val subsonicResponse: GetPlaylistBody,
)
@Serializable
data class GetPlaylistBody(
val playlist: PlaylistDetailDto? = null,
val error: SubsonicError? = null,
)
/** `createPlaylist` and `updatePlaylist` (when it echoes the playlist back) share this shape. */
@Serializable
data class PlaylistResponse(
@SerialName("subsonic-response") val subsonicResponse: PlaylistBody,
)
@Serializable
data class PlaylistBody(
val playlist: PlaylistDetailDto? = null,
val error: SubsonicError? = null,
)
@@ -0,0 +1,63 @@
package com.InfernalAquatics.deepwave.data.playlist
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistEntity
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistSyncState
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistTrackEntity
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistWithCount
import com.InfernalAquatics.deepwave.data.model.Playlist
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.network.model.SongDto
internal fun PlaylistWithCount.toDomain() = Playlist(
id = entity.localId,
name = entity.name,
coverArtId = entity.coverArtId,
trackCount = trackCount,
isSynced = entity.syncState == PlaylistSyncState.SYNCED,
)
internal fun PlaylistEntity.toDomain(trackCount: Int) = Playlist(
id = localId,
name = name,
coverArtId = coverArtId,
trackCount = trackCount,
isSynced = syncState == PlaylistSyncState.SYNCED,
)
internal fun PlaylistTrackEntity.toDomain() = Song(
id = trackId,
title = title,
albumName = albumName,
artistName = artistName,
albumId = albumId,
artistId = artistId,
coverArtId = coverArtId,
track = null,
durationSeconds = durationSeconds,
)
internal fun Song.toTrackEntity(playlistLocalId: String, position: Int) = PlaylistTrackEntity(
playlistLocalId = playlistLocalId,
position = position,
trackId = id,
title = title,
artistName = artistName,
albumName = albumName,
albumId = albumId,
artistId = artistId,
coverArtId = coverArtId,
durationSeconds = durationSeconds,
)
internal fun SongDto.toTrackEntity(playlistLocalId: String, position: Int) = PlaylistTrackEntity(
playlistLocalId = playlistLocalId,
position = position,
trackId = id,
title = title,
artistName = artist,
albumName = album,
albumId = albumId,
artistId = artistId,
coverArtId = coverArt,
durationSeconds = duration,
)
@@ -0,0 +1,144 @@
package com.InfernalAquatics.deepwave.data.playlist
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistDao
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistEntity
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistSyncState
import com.InfernalAquatics.deepwave.data.model.Playlist
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.repository.ServerRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Local-first playlist storage: every read/write goes through Room first (fully offline-capable),
* with mutations marking an outbox `syncState` that [PlaylistSyncWorker] later pushes to the
* server. Mirrors [com.InfernalAquatics.deepwave.data.download.DownloadRepository]'s
* Room-plus-WorkManager split with [PlaylistSyncWorker] standing in for DownloadWorker.
*/
@Singleton
class PlaylistRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val playlistDao: PlaylistDao,
serverRepository: ServerRepository,
) {
private val workManager = WorkManager.getInstance(context)
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
init {
// A sync pass whenever the app has a live session - covers both a fresh login and a
// cold start that's already logged in, without a separate hook from the login screen.
repositoryScope.launch {
serverRepository.isLoggedIn.collect { loggedIn -> if (loggedIn) enqueueSync() }
}
enqueuePeriodicSync()
}
fun observePlaylists(): Flow<List<Playlist>> =
playlistDao.observePlaylistsWithCount().map { list -> list.map { it.toDomain() } }
fun observePlaylist(localId: String): Flow<Playlist?> = combine(
playlistDao.observePlaylist(localId),
playlistDao.observeTracks(localId),
) { entity, tracks -> entity?.toDomain(trackCount = tracks.size) }
fun observeTracks(localId: String): Flow<List<Song>> =
playlistDao.observeTracks(localId).map { list -> list.map { it.toDomain() } }
suspend fun createPlaylist(name: String, initialTracks: List<Song> = emptyList()): String {
val localId = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
playlistDao.upsertPlaylist(
PlaylistEntity(
localId = localId,
serverId = null,
name = name,
coverArtId = null,
createdAt = now,
updatedAt = now,
syncState = PlaylistSyncState.PENDING_CREATE,
),
)
if (initialTracks.isNotEmpty()) {
playlistDao.insertTracks(initialTracks.mapIndexed { index, song -> song.toTrackEntity(localId, index) })
}
enqueueSync()
return localId
}
suspend fun addTrack(localId: String, song: Song) {
val tracks = playlistDao.getTracks(localId)
if (tracks.any { it.trackId == song.id }) return
playlistDao.insertTracks(listOf(song.toTrackEntity(localId, tracks.size)))
markPendingUpdate(localId)
enqueueSync()
}
suspend fun removeTrack(localId: String, position: Int) {
val tracks = playlistDao.getTracks(localId).toMutableList()
if (position !in tracks.indices) return
tracks.removeAt(position)
playlistDao.replaceTracks(localId, tracks.mapIndexed { index, track -> track.copy(position = index) })
markPendingUpdate(localId)
enqueueSync()
}
suspend fun deletePlaylist(localId: String) {
val entity = playlistDao.getPlaylist(localId) ?: return
if (entity.serverId == null) {
// Never synced - nothing server-side to tell about, safe to just drop it.
playlistDao.deletePlaylistRow(localId)
} else {
playlistDao.upsertPlaylist(entity.copy(syncState = PlaylistSyncState.PENDING_DELETE))
enqueueSync()
}
}
private suspend fun markPendingUpdate(localId: String) {
val entity = playlistDao.getPlaylist(localId) ?: return
if (entity.syncState == PlaylistSyncState.SYNCED) {
playlistDao.upsertPlaylist(
entity.copy(syncState = PlaylistSyncState.PENDING_UPDATE, updatedAt = System.currentTimeMillis()),
)
}
}
private fun enqueueSync() {
// REPLACE: PlaylistSyncWorker takes no input data, it just scans Room for whatever's
// currently pending - state lives there, not in the WorkRequest, so stomping a queued
// request with a fresh one from a later mutation loses nothing.
val request = OneTimeWorkRequestBuilder<PlaylistSyncWorker>()
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
workManager.enqueueUniqueWork(SYNC_WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
private fun enqueuePeriodicSync() {
val request = PeriodicWorkRequestBuilder<PlaylistSyncWorker>(30, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
workManager.enqueueUniquePeriodicWork(PERIODIC_SYNC_WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
}
private companion object {
const val SYNC_WORK_NAME = "playlist-sync"
const val PERIODIC_SYNC_WORK_NAME = "playlist-sync-periodic"
}
}
@@ -0,0 +1,25 @@
package com.InfernalAquatics.deepwave.data.playlist
/** The `updatePlaylist` call needed to turn a server-side track order into the local one. */
data class PlaylistSyncDelta(val songIdsToAdd: List<String>, val indicesToRemove: List<Int>)
/**
* Pure diff between the server's current track order and the local (authoritative) one - kept
* separate from PlaylistSyncWorker so it's testable with no Room/WorkManager/network involved.
*
* [indicesToRemove] are positions in [remoteIds] itself (Subsonic's `songIndexToRemove` is
* index-based against the server's current list), so they stay correct even when additions are
* applied in the same call - see SubsonicApi.updatePlaylist's doc comment.
*
* This is add/remove only: a pure reorder of the same track set (no additions or removals)
* produces an empty delta and is never pushed. The app doesn't offer track reordering, so this
* is a deliberate scope cut, not an oversight - the last-write-wins comment on PlaylistSyncState
* covers the equivalent whole-playlist case.
*/
fun computePlaylistSyncDelta(remoteIds: List<String>, localIds: List<String>): PlaylistSyncDelta {
val localIdSet = localIds.toSet()
val remoteIdSet = remoteIds.toSet()
val songIdsToAdd = localIds.filterNot { it in remoteIdSet }
val indicesToRemove = remoteIds.withIndex().filter { (_, id) -> id !in localIdSet }.map { it.index }
return PlaylistSyncDelta(songIdsToAdd, indicesToRemove)
}
@@ -0,0 +1,137 @@
package com.InfernalAquatics.deepwave.data.playlist
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistDao
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistEntity
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistSyncState
import com.InfernalAquatics.deepwave.data.network.SubsonicApi
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import retrofit2.HttpException
import java.io.IOException
import java.util.UUID
/**
* Pushes queued local playlist mutations to the server, then pulls the server's current
* playlists back down. One unique work item for the whole pass (not per-playlist) - simpler,
* and a full pass is cheap since it's just a handful of small requests.
*
* Conflict handling is last-write-wins at the whole-playlist level: [pushPending] always runs
* before [pullRemote], so a playlist with a local mutation queued gets its push applied first,
* and [pullRemote] then treats that push's result as the new truth (it skips any local playlist
* that isn't SYNCED, see below) - the local copy always wins over whatever pullRemote would
* otherwise have pulled down for it. This is a v1 simplification: concurrent edits to the same
* playlist from two devices (or the Navidrome web UI) at once can still clobber one or the
* other, depending purely on which sync pass runs last.
*/
@HiltWorker
class PlaylistSyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val subsonicApi: SubsonicApi,
private val playlistDao: PlaylistDao,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
pushPending()
pullRemote()
Result.success()
} catch (e: IOException) {
if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure()
} catch (e: HttpException) {
if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure()
}
private suspend fun pushPending() {
playlistDao.getPendingPlaylists().forEach { entity ->
when (entity.syncState) {
PlaylistSyncState.PENDING_CREATE -> pushCreate(entity)
PlaylistSyncState.PENDING_UPDATE -> pushUpdate(entity)
PlaylistSyncState.PENDING_DELETE -> pushDelete(entity)
PlaylistSyncState.SYNCED -> Unit
}
}
}
private suspend fun pushCreate(entity: PlaylistEntity) {
val tracks = playlistDao.getTracks(entity.localId)
val response = subsonicApi.createPlaylist(name = entity.name, songIds = tracks.map { it.trackId })
val created = response.subsonicResponse.playlist ?: return
playlistDao.upsertPlaylist(
entity.copy(serverId = created.id, coverArtId = created.coverArt, syncState = PlaylistSyncState.SYNCED),
)
}
private suspend fun pushUpdate(entity: PlaylistEntity) {
// Shouldn't happen - PENDING_UPDATE is only ever set on a playlist that already has a
// serverId (see PlaylistRepository.markPendingUpdate) - but if it did, there's nothing
// to diff against yet; leave it queued rather than silently dropping the mutation.
val serverId = entity.serverId ?: return
val remoteTracks = subsonicApi.getPlaylist(serverId).subsonicResponse.playlist?.entry.orEmpty()
val localTracks = playlistDao.getTracks(entity.localId)
val delta = computePlaylistSyncDelta(remoteIds = remoteTracks.map { it.id }, localIds = localTracks.map { it.trackId })
if (delta.songIdsToAdd.isNotEmpty() || delta.indicesToRemove.isNotEmpty()) {
subsonicApi.updatePlaylist(
playlistId = serverId,
songIdToAdd = delta.songIdsToAdd,
songIndexToRemove = delta.indicesToRemove,
)
}
playlistDao.upsertPlaylist(entity.copy(syncState = PlaylistSyncState.SYNCED))
}
private suspend fun pushDelete(entity: PlaylistEntity) {
val serverId = entity.serverId
if (serverId != null) {
// A 404 here just means it's already gone server-side (e.g. deleted from the web
// UI too) - either way the local row should still go.
runCatching { subsonicApi.deletePlaylist(serverId) }
}
playlistDao.deletePlaylistRow(entity.localId)
}
private suspend fun pullRemote() {
val remotePlaylists = subsonicApi.getPlaylists().subsonicResponse.playlists?.playlist.orEmpty()
val remoteIds = remotePlaylists.map { it.id }.toSet()
// A synced local playlist whose serverId no longer appears server-side was deleted
// elsewhere (e.g. the Navidrome web UI) - drop it locally too.
playlistDao.getSyncedPlaylists().forEach { local ->
if (local.serverId != null && local.serverId !in remoteIds) {
playlistDao.deletePlaylistRow(local.localId)
}
}
remotePlaylists.forEach { summary ->
val existing = playlistDao.getPlaylistByServerId(summary.id)
// A playlist with local changes still queued keeps its local copy as the source of
// truth for this pass - pushPending() already ran above, so anything actually SYNCED
// by now reflects that push; anything still pending failed to push and shouldn't be
// overwritten by a pull.
if (existing != null && existing.syncState != PlaylistSyncState.SYNCED) return@forEach
val detail = subsonicApi.getPlaylist(summary.id).subsonicResponse.playlist ?: return@forEach
val localId = existing?.localId ?: UUID.randomUUID().toString()
playlistDao.upsertPlaylist(
PlaylistEntity(
localId = localId,
serverId = summary.id,
name = detail.name,
coverArtId = detail.coverArt,
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
syncState = PlaylistSyncState.SYNCED,
),
)
playlistDao.replaceTracks(localId, detail.entry.mapIndexed { index, song -> song.toTrackEntity(localId, index) })
}
}
private companion object {
const val MAX_RETRIES = 3
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.room.Room
import com.InfernalAquatics.deepwave.data.local.DeepwaveDatabase
import com.InfernalAquatics.deepwave.data.local.download.DownloadDao
import com.InfernalAquatics.deepwave.data.local.playlist.PlaylistDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -26,4 +27,7 @@ object DatabaseModule {
@Provides
fun provideDownloadDao(database: DeepwaveDatabase): DownloadDao = database.downloadDao()
@Provides
fun providePlaylistDao(database: DeepwaveDatabase): PlaylistDao = database.playlistDao()
}
@@ -0,0 +1,13 @@
package com.InfernalAquatics.deepwave.di
import com.InfernalAquatics.deepwave.data.playlist.PlaylistRepository
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/** Lets [com.InfernalAquatics.deepwave.DeepwaveApplication] force PlaylistRepository to construct at startup. */
@EntryPoint
@InstallIn(SingletonComponent::class)
interface PlaylistRepositoryEntryPoint {
fun playlistRepository(): PlaylistRepository
}
@@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -45,6 +46,7 @@ import com.InfernalAquatics.deepwave.ui.components.Artwork
import com.InfernalAquatics.deepwave.ui.components.BitratePickerSheet
import com.InfernalAquatics.deepwave.ui.components.DownloadStateBadge
import com.InfernalAquatics.deepwave.ui.components.TrackRow
import com.InfernalAquatics.deepwave.ui.playlist.AddToPlaylistSheet
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@@ -67,6 +69,8 @@ fun AlbumDetailScreen(
onConfirmDownload = viewModel::confirmDownload,
onDismissDownloadPicker = viewModel::dismissDownloadPicker,
observeDownloadState = viewModel::observeDownloadState,
onAddToPlaylist = viewModel::requestAddToPlaylist,
onDismissAddToPlaylist = viewModel::dismissAddToPlaylist,
)
}
@@ -82,6 +86,8 @@ private fun AlbumDetailContent(
onConfirmDownload: (BitrateOption) -> Unit = {},
onDismissDownloadPicker: () -> Unit = {},
observeDownloadState: (String) -> Flow<TrackDownloadState> = { flowOf(TrackDownloadState.NotDownloaded) },
onAddToPlaylist: (Int) -> Unit = {},
onDismissAddToPlaylist: () -> Unit = {},
) {
Scaffold(
topBar = {
@@ -112,6 +118,9 @@ private fun AlbumDetailContent(
coverArtUrl = coverArtUrl(song.coverArtId ?: uiState.album?.coverArtId),
onClick = { onSongClick(index) },
trailingContent = {
IconButton(onClick = { onAddToPlaylist(index) }) {
Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = stringResource(R.string.playlist_add_to))
}
DownloadBadge(
trackId = song.id,
observeState = observeDownloadState,
@@ -130,6 +139,10 @@ private fun AlbumDetailContent(
defaultOption = defaultBitrate,
)
}
uiState.pendingAddToPlaylistSong?.let { song ->
AddToPlaylistSheet(song = song, onDismiss = onDismissAddToPlaylist)
}
}
}
}
@@ -41,6 +41,7 @@ data class AlbumDetailUiState(
val songs: List<Song> = emptyList(),
val errorMessage: String? = null,
val pendingDownloadTarget: DownloadTarget? = null,
val pendingAddToPlaylistSong: Song? = null,
)
@UnstableApi
@@ -75,6 +76,15 @@ class AlbumDetailViewModel @Inject constructor(
fun observeDownloadState(trackId: String): Flow<TrackDownloadState> = downloadRepository.observeState(trackId)
fun requestAddToPlaylist(index: Int) {
val song = _uiState.value.songs.getOrNull(index) ?: return
_uiState.update { it.copy(pendingAddToPlaylistSong = song) }
}
fun dismissAddToPlaylist() {
_uiState.update { it.copy(pendingAddToPlaylistSong = null) }
}
fun requestDownloadSong(index: Int) {
_uiState.update { it.copy(pendingDownloadTarget = DownloadTarget.Track(index)) }
}
@@ -0,0 +1,62 @@
package com.InfernalAquatics.deepwave.ui.library
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.ui.playlist.PlaylistsScreen
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
private enum class LibrarySection(val labelRes: Int) {
Playlists(R.string.library_section_playlists),
Artists(R.string.library_section_artists),
}
/** The bottom-nav Library tab's content: a Playlists/Artists switcher, Spotify's own Library
* tab convention, rather than two separate destinations off the bottom nav. */
@Composable
fun LibraryScreen(
onArtistClick: (String) -> Unit,
onPlaylistClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var section by remember { mutableStateOf(LibrarySection.Playlists) }
Column(modifier = modifier.fillMaxSize()) {
Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
LibrarySection.entries.forEach { entry ->
FilterChip(
selected = entry == section,
onClick = { section = entry },
label = { Text(stringResource(entry.labelRes)) },
modifier = Modifier.padding(end = 8.dp),
)
}
}
when (section) {
LibrarySection.Playlists -> PlaylistsScreen(onPlaylistClick = onPlaylistClick, modifier = Modifier.weight(1f))
LibrarySection.Artists -> ArtistsScreen(onArtistClick = onArtistClick, modifier = Modifier.weight(1f))
}
}
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun LibraryScreenPreview() {
DeepwaveTheme {
LibraryScreen(onArtistClick = {}, onPlaylistClick = {})
}
}
@@ -25,6 +25,7 @@ import com.InfernalAquatics.deepwave.ui.library.ArtistDetailScreen
import com.InfernalAquatics.deepwave.ui.login.LoginScreen
import com.InfernalAquatics.deepwave.ui.player.NowPlayingScreen
import com.InfernalAquatics.deepwave.ui.player.PlayerViewModel
import com.InfernalAquatics.deepwave.ui.playlist.PlaylistDetailScreen
import com.InfernalAquatics.deepwave.ui.settings.SettingsScreen
/**
@@ -93,6 +94,7 @@ fun DeepwaveNavHost(
onOpenSettings = { navController.navigate(Route.Settings) },
onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) },
onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) },
onOpenPlaylist = { playlistId -> navController.navigate(Route.PlaylistDetail(playlistId)) },
)
}
composable<Route.Settings> {
@@ -118,6 +120,9 @@ fun DeepwaveNavHost(
composable<Route.AlbumDetail> {
AlbumDetailScreen(onBack = { navController.popBackStack() })
}
composable<Route.PlaylistDetail> {
PlaylistDetailScreen(onBack = { navController.popBackStack() })
}
composable<Route.NowPlaying> {
NowPlayingScreen(onBack = { navController.popBackStack() })
}
@@ -23,8 +23,8 @@ import androidx.compose.ui.tooling.preview.Preview
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.ui.components.BottomNavBar
import com.InfernalAquatics.deepwave.ui.components.BottomTab
import com.InfernalAquatics.deepwave.ui.library.ArtistsScreen
import com.InfernalAquatics.deepwave.ui.library.HomeScreen
import com.InfernalAquatics.deepwave.ui.library.LibraryScreen
import com.InfernalAquatics.deepwave.ui.library.SearchScreen
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
@@ -40,6 +40,7 @@ fun MainScreen(
onOpenSettings: () -> Unit,
onOpenArtist: (String) -> Unit,
onOpenAlbum: (String) -> Unit,
onOpenPlaylist: (String) -> Unit,
) {
var selectedTab by remember { mutableStateOf(BottomTab.Home) }
@@ -66,7 +67,7 @@ fun MainScreen(
when (selectedTab) {
BottomTab.Home -> HomeScreen(onAlbumClick = onOpenAlbum)
BottomTab.Search -> SearchScreen(onArtistClick = onOpenArtist, onAlbumClick = onOpenAlbum)
BottomTab.Library -> ArtistsScreen(onArtistClick = onOpenArtist)
BottomTab.Library -> LibraryScreen(onArtistClick = onOpenArtist, onPlaylistClick = onOpenPlaylist)
}
}
}
@@ -77,6 +78,6 @@ fun MainScreen(
@Composable
private fun MainScreenPreview() {
DeepwaveTheme {
MainScreen(onOpenSettings = {}, onOpenArtist = {}, onOpenAlbum = {})
MainScreen(onOpenSettings = {}, onOpenArtist = {}, onOpenAlbum = {}, onOpenPlaylist = {})
}
}
@@ -10,4 +10,5 @@ sealed interface Route {
@Serializable data class AlbumDetail(val albumId: String) : Route
@Serializable data object NowPlaying : Route
@Serializable data object Downloads : Route
@Serializable data class PlaylistDetail(val playlistId: String) : Route
}
@@ -0,0 +1,103 @@
package com.InfernalAquatics.deepwave.ui.playlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.data.model.Playlist
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddToPlaylistSheet(
song: Song,
onDismiss: () -> Unit,
viewModel: AddToPlaylistViewModel = hiltViewModel(),
) {
val playlists by viewModel.playlists.collectAsState()
var isCreating by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
if (isCreating) {
CreatePlaylistFields(
onCreate = { name ->
viewModel.createPlaylistAndAdd(name, song)
onDismiss()
},
)
} else {
AddToPlaylistContent(
playlists = playlists,
onNewPlaylist = { isCreating = true },
onPlaylistClick = { playlistId ->
viewModel.addToPlaylist(playlistId, song)
onDismiss()
},
)
}
}
}
@Composable
private fun AddToPlaylistContent(
playlists: List<Playlist>,
onNewPlaylist: () -> Unit,
onPlaylistClick: (String) -> Unit,
) {
LazyColumn {
item {
ListItem(
headlineContent = { Text(stringResource(R.string.playlist_new)) },
leadingContent = { Icon(Icons.Filled.Add, contentDescription = null) },
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onNewPlaylist),
)
}
items(playlists, key = { it.id }) { playlist ->
ListItem(
headlineContent = { Text(playlist.name) },
supportingContent = {
Text(pluralStringResource(R.plurals.playlist_track_count, playlist.trackCount, playlist.trackCount))
},
modifier = Modifier
.fillMaxWidth()
.clickable { onPlaylistClick(playlist.id) },
)
}
}
}
private val samplePlaylists = listOf(
Playlist(id = "1", name = "Riddim Favs", coverArtId = null, trackCount = 12, isSynced = true),
Playlist(id = "2", name = "Chill Study", coverArtId = null, trackCount = 4, isSynced = false),
)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411)
@Composable
private fun AddToPlaylistContentPreview() {
DeepwaveTheme {
AddToPlaylistContent(playlists = samplePlaylists, onNewPlaylist = {}, onPlaylistClick = {})
}
}
@@ -0,0 +1,29 @@
package com.InfernalAquatics.deepwave.ui.playlist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.InfernalAquatics.deepwave.data.model.Playlist
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.playlist.PlaylistRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AddToPlaylistViewModel @Inject constructor(
private val playlistRepository: PlaylistRepository,
) : ViewModel() {
val playlists: StateFlow<List<Playlist>> = playlistRepository.observePlaylists()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun addToPlaylist(playlistId: String, song: Song) {
viewModelScope.launch { playlistRepository.addTrack(playlistId, song) }
}
fun createPlaylistAndAdd(name: String, song: Song) {
viewModelScope.launch { playlistRepository.createPlaylist(name, listOf(song)) }
}
}
@@ -0,0 +1,68 @@
package com.InfernalAquatics.deepwave.ui.playlist
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreatePlaylistSheet(onCreate: (String) -> Unit, onDismiss: () -> Unit) {
ModalBottomSheet(onDismissRequest = onDismiss) {
CreatePlaylistFields(onCreate = onCreate)
}
}
/** The name field and create button alone, with no [ModalBottomSheet] of its own - reused inline
* by [AddToPlaylistSheet], which already owns a sheet and just swaps its content into this. */
@Composable
fun CreatePlaylistFields(onCreate: (String) -> Unit, modifier: Modifier = Modifier) {
var name by remember { mutableStateOf("") }
Column(modifier = modifier.padding(16.dp).padding(bottom = 24.dp)) {
Text(stringResource(R.string.playlist_create_title), style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.playlist_name_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { if (name.isNotBlank()) onCreate(name.trim()) },
enabled = name.isNotBlank(),
modifier = Modifier.align(Alignment.End),
) {
Text(stringResource(R.string.playlist_create_action))
}
}
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411)
@Composable
private fun CreatePlaylistFieldsPreview() {
DeepwaveTheme {
CreatePlaylistFields(onCreate = {})
}
}
@@ -0,0 +1,244 @@
package com.InfernalAquatics.deepwave.ui.playlist
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RemoveCircleOutline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.data.download.BitrateOption
import com.InfernalAquatics.deepwave.data.download.TrackDownloadState
import com.InfernalAquatics.deepwave.data.model.Playlist
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.ui.components.Artwork
import com.InfernalAquatics.deepwave.ui.components.BitratePickerSheet
import com.InfernalAquatics.deepwave.ui.components.DownloadStateBadge
import com.InfernalAquatics.deepwave.ui.components.TrackRow
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@UnstableApi
@Composable
fun PlaylistDetailScreen(
onBack: () -> Unit,
viewModel: PlaylistDetailViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
val defaultBitrate by viewModel.defaultBitrate.collectAsState()
PlaylistDetailContent(
uiState = uiState,
defaultBitrate = defaultBitrate,
onBack = onBack,
onTrackClick = viewModel::playTrack,
onRemoveTrack = viewModel::removeTrack,
observeDownloadState = viewModel::observeDownloadState,
onDownloadPlaylist = viewModel::requestDownloadPlaylist,
onConfirmDownload = viewModel::confirmDownload,
onDismissDownloadPicker = viewModel::dismissDownloadPicker,
onDeleteClick = viewModel::requestConfirmDelete,
onDismissDelete = viewModel::dismissConfirmDelete,
onConfirmDelete = { viewModel.confirmDelete(onDeleted = onBack) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PlaylistDetailContent(
uiState: PlaylistDetailUiState,
defaultBitrate: BitrateOption,
onBack: () -> Unit,
onTrackClick: (Int) -> Unit = {},
onRemoveTrack: (Int) -> Unit = {},
observeDownloadState: (String) -> Flow<TrackDownloadState> = { flowOf(TrackDownloadState.NotDownloaded) },
onDownloadPlaylist: () -> Unit = {},
onConfirmDownload: (BitrateOption) -> Unit = {},
onDismissDownloadPicker: () -> Unit = {},
onDeleteClick: () -> Unit = {},
onDismissDelete: () -> Unit = {},
onConfirmDelete: () -> Unit = {},
) {
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
IconButton(onClick = onDeleteClick) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.playlist_delete))
}
},
)
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
if (uiState.playlist == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
PlaylistHeader(
playlist = uiState.playlist,
onPlay = { onTrackClick(0) },
onDownload = onDownloadPlaylist,
)
}
if (uiState.tracks.isEmpty()) {
item {
Text(
text = stringResource(R.string.playlist_empty_tracks),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.fillMaxWidth().padding(24.dp),
textAlign = TextAlign.Center,
)
}
} else {
itemsIndexed(uiState.tracks, key = { _, track -> track.id }) { index, track ->
TrackRow(
title = track.title,
subtitle = track.artistName.orEmpty(),
coverArtUrl = coverArtUrl(track.coverArtId),
onClick = { onTrackClick(index) },
trailingContent = {
DownloadBadge(trackId = track.id, observeState = observeDownloadState)
IconButton(onClick = { onRemoveTrack(index) }) {
Icon(
Icons.Filled.RemoveCircleOutline,
contentDescription = stringResource(R.string.playlist_remove_track),
)
}
},
)
}
}
}
}
if (uiState.isPickingDownloadQuality) {
BitratePickerSheet(onSelect = onConfirmDownload, onDismiss = onDismissDownloadPicker, defaultOption = defaultBitrate)
}
}
}
if (uiState.isConfirmingDelete) {
AlertDialog(
onDismissRequest = onDismissDelete,
title = { Text(stringResource(R.string.playlist_delete_confirm_title)) },
text = { Text(stringResource(R.string.playlist_delete_confirm_message)) },
confirmButton = {
TextButton(onClick = onConfirmDelete) { Text(stringResource(R.string.action_delete)) }
},
dismissButton = {
TextButton(onClick = onDismissDelete) { Text(stringResource(R.string.action_cancel)) }
},
)
}
}
/** Status only here - no per-track download button, just the header's "Download playlist"
* (unlike AlbumDetailScreen). A failed track can still be retried from its album's own screen. */
@Composable
private fun DownloadBadge(trackId: String, observeState: (String) -> Flow<TrackDownloadState>) {
val flow = remember(trackId) { observeState(trackId) }
val state by flow.collectAsState(initial = TrackDownloadState.NotDownloaded)
DownloadStateBadge(state = state, onClick = {})
}
@Composable
private fun PlaylistHeader(playlist: Playlist, onPlay: () -> Unit, onDownload: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Artwork(imageUrl = coverArtUrl(playlist.coverArtId), size = 180.dp)
Spacer(modifier = Modifier.height(16.dp))
Text(text = playlist.name, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
Text(
text = pluralStringResource(R.plurals.playlist_track_count, playlist.trackCount, playlist.trackCount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Row {
OutlinedButton(onClick = onPlay) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
Text(stringResource(R.string.player_play))
}
Spacer(modifier = Modifier.padding(start = 8.dp))
OutlinedButton(onClick = onDownload) {
Icon(Icons.Filled.Download, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
Text(stringResource(R.string.playlist_download))
}
}
}
}
private val samplePlaylist = Playlist(id = "1", name = "Riddim Favs", coverArtId = null, trackCount = 2, isSynced = true)
private val sampleTracks = List(2) { index ->
Song(
id = "sample-$index",
title = "Sample Track ${index + 1}",
albumName = "Sample Album",
artistName = "Sample Artist",
albumId = "album",
artistId = "artist",
coverArtId = null,
track = index + 1,
durationSeconds = 200,
)
}
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun PlaylistDetailContentPreview() {
DeepwaveTheme {
PlaylistDetailContent(
uiState = PlaylistDetailUiState(playlist = samplePlaylist, tracks = sampleTracks),
defaultBitrate = BitrateOption.Normal,
onBack = {},
)
}
}
@@ -0,0 +1,113 @@
package com.InfernalAquatics.deepwave.ui.playlist
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.navigation.toRoute
import com.InfernalAquatics.deepwave.data.download.BitrateOption
import com.InfernalAquatics.deepwave.data.download.DownloadRepository
import com.InfernalAquatics.deepwave.data.download.TrackDownloadState
import com.InfernalAquatics.deepwave.data.model.Playlist
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.playlist.PlaylistRepository
import com.InfernalAquatics.deepwave.data.settings.DownloadPreferences
import com.InfernalAquatics.deepwave.media.PlaybackController
import com.InfernalAquatics.deepwave.media.toMediaItem
import com.InfernalAquatics.deepwave.ui.navigation.Route
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/** What a bitrate pick from the sheet applies to - the whole playlist is the only option here,
* unlike AlbumDetailViewModel's per-track/whole-album split, since there's no per-track download
* button on this screen (only the header's "Download playlist" action). */
data class PlaylistDetailUiState(
val playlist: Playlist? = null,
val tracks: List<Song> = emptyList(),
val isConfirmingDelete: Boolean = false,
val isPickingDownloadQuality: Boolean = false,
)
@UnstableApi
@HiltViewModel
class PlaylistDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val playlistRepository: PlaylistRepository,
private val playbackController: PlaybackController,
private val downloadRepository: DownloadRepository,
private val downloadPreferences: DownloadPreferences,
) : ViewModel() {
private val playlistId = savedStateHandle.toRoute<Route.PlaylistDetail>().playlistId
private val _isConfirmingDelete = MutableStateFlow(false)
private val _isPickingDownloadQuality = MutableStateFlow(false)
val uiState: StateFlow<PlaylistDetailUiState> = combine(
playlistRepository.observePlaylist(playlistId),
playlistRepository.observeTracks(playlistId),
_isConfirmingDelete,
_isPickingDownloadQuality,
) { playlist, tracks, isConfirmingDelete, isPickingDownloadQuality ->
PlaylistDetailUiState(playlist, tracks, isConfirmingDelete, isPickingDownloadQuality)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PlaylistDetailUiState())
val defaultBitrate: StateFlow<BitrateOption> = downloadPreferences.defaultBitrate
.stateIn(viewModelScope, SharingStarted.Eagerly, BitrateOption.Normal)
fun playTrack(index: Int) {
val tracks = uiState.value.tracks
if (index !in tracks.indices) return
viewModelScope.launch {
playbackController.ensureConnected()
playbackController.playQueue(tracks.map { it.toMediaItem() }, startIndex = index)
}
}
fun removeTrack(index: Int) {
viewModelScope.launch { playlistRepository.removeTrack(playlistId, index) }
}
fun observeDownloadState(trackId: String): Flow<TrackDownloadState> = downloadRepository.observeState(trackId)
fun requestConfirmDelete() {
_isConfirmingDelete.update { true }
}
fun dismissConfirmDelete() {
_isConfirmingDelete.update { false }
}
fun confirmDelete(onDeleted: () -> Unit) {
viewModelScope.launch {
playlistRepository.deletePlaylist(playlistId)
onDeleted()
}
_isConfirmingDelete.update { false }
}
fun requestDownloadPlaylist() {
_isPickingDownloadQuality.update { true }
}
fun dismissDownloadPicker() {
_isPickingDownloadQuality.update { false }
}
fun confirmDownload(bitrateOption: BitrateOption) {
val tracks = uiState.value.tracks
viewModelScope.launch {
val wifiOnly = downloadPreferences.wifiOnly.first()
tracks.forEach { downloadRepository.enqueue(it, bitrateOption, wifiOnly) }
}
_isPickingDownloadQuality.update { false }
}
}
@@ -0,0 +1,97 @@
package com.InfernalAquatics.deepwave.ui.playlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.InfernalAquatics.deepwave.R
import com.InfernalAquatics.deepwave.data.model.Playlist
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.ui.components.TrackRow
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
@Composable
fun PlaylistsScreen(
onPlaylistClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: PlaylistsViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
PlaylistsContent(
uiState = uiState,
onPlaylistClick = onPlaylistClick,
onNewPlaylist = viewModel::openCreate,
modifier = modifier,
)
if (uiState.isCreating) {
CreatePlaylistSheet(onCreate = viewModel::create, onDismiss = viewModel::dismissCreate)
}
}
@Composable
private fun PlaylistsContent(
uiState: PlaylistsUiState,
onPlaylistClick: (String) -> Unit,
onNewPlaylist: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier.fillMaxSize()) {
item {
ListItem(
headlineContent = { Text(stringResource(R.string.playlist_new)) },
leadingContent = { Icon(Icons.Filled.Add, contentDescription = null) },
modifier = Modifier.clickable(onClick = onNewPlaylist),
)
}
if (uiState.playlists.isEmpty()) {
item {
ListItem(headlineContent = { Text(stringResource(R.string.playlist_empty)) })
}
} else {
items(uiState.playlists, key = { it.id }) { playlist ->
TrackRow(
title = playlist.name,
subtitle = pluralStringResource(R.plurals.playlist_track_count, playlist.trackCount, playlist.trackCount),
coverArtUrl = coverArtUrl(playlist.coverArtId),
onClick = { onPlaylistClick(playlist.id) },
)
}
}
}
}
private val samplePlaylists = listOf(
Playlist(id = "1", name = "Riddim Favs", coverArtId = null, trackCount = 12, isSynced = true),
Playlist(id = "2", name = "Chill Study", coverArtId = null, trackCount = 4, isSynced = false),
)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun PlaylistsContentPreview() {
DeepwaveTheme {
PlaylistsContent(uiState = PlaylistsUiState(playlists = samplePlaylists), onPlaylistClick = {}, onNewPlaylist = {})
}
}
@Preview(name = "Empty", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
@Composable
private fun PlaylistsContentEmptyPreview() {
DeepwaveTheme {
PlaylistsContent(uiState = PlaylistsUiState(), onPlaylistClick = {}, onNewPlaylist = {})
}
}
@@ -0,0 +1,43 @@
package com.InfernalAquatics.deepwave.ui.playlist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.InfernalAquatics.deepwave.data.model.Playlist
import com.InfernalAquatics.deepwave.data.playlist.PlaylistRepository
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.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class PlaylistsUiState(val playlists: List<Playlist> = emptyList(), val isCreating: Boolean = false)
@HiltViewModel
class PlaylistsViewModel @Inject constructor(
private val playlistRepository: PlaylistRepository,
) : ViewModel() {
private val _isCreating = MutableStateFlow(false)
val uiState: StateFlow<PlaylistsUiState> = combine(
playlistRepository.observePlaylists(),
_isCreating,
) { playlists, isCreating -> PlaylistsUiState(playlists, isCreating) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PlaylistsUiState())
fun openCreate() {
_isCreating.update { true }
}
fun dismissCreate() {
_isCreating.update { false }
}
fun create(name: String) {
viewModelScope.launch { playlistRepository.createPlaylist(name) }
_isCreating.update { false }
}
}
+23
View File
@@ -47,4 +47,27 @@
<string name="search_section_artists">Artists</string>
<string name="search_section_albums">Albums</string>
<string name="search_section_songs">Songs</string>
<string name="library_section_playlists">Playlists</string>
<string name="library_section_artists">Artists</string>
<string name="playlist_new">New playlist</string>
<string name="playlist_create_title">New playlist</string>
<string name="playlist_name_label">Name</string>
<string name="playlist_create_action">Create</string>
<string name="playlist_empty">No playlists yet</string>
<string name="playlist_empty_tracks">No tracks yet</string>
<string name="playlist_add_to">Add to playlist</string>
<string name="playlist_remove_track">Remove from playlist</string>
<string name="playlist_download">Download playlist</string>
<string name="playlist_delete">Delete playlist</string>
<string name="playlist_delete_confirm_title">Delete playlist?</string>
<string name="playlist_delete_confirm_message">This deletes it from the server too, for every device.</string>
<string name="action_cancel">Cancel</string>
<string name="action_delete">Delete</string>
<plurals name="playlist_track_count">
<item quantity="one">%d song</item>
<item quantity="other">%d songs</item>
</plurals>
</resources>
@@ -0,0 +1,68 @@
package com.InfernalAquatics.deepwave.data.playlist
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Covers [computePlaylistSyncDelta] in isolation - the one piece of PlaylistSyncWorker's
* push/pull logic that's pure and worth pinning down precisely, since a wrong index here
* deletes the wrong track from the server, not just a locally-visible glitch.
*/
class PlaylistSyncDeltaTest {
@Test
fun `no changes when local matches remote`() {
val delta = computePlaylistSyncDelta(remoteIds = listOf("a", "b", "c"), localIds = listOf("a", "b", "c"))
assertEquals(emptyList<String>(), delta.songIdsToAdd)
assertEquals(emptyList<Int>(), delta.indicesToRemove)
}
@Test
fun `detects a track added locally`() {
val delta = computePlaylistSyncDelta(remoteIds = listOf("a", "b"), localIds = listOf("a", "b", "c"))
assertEquals(listOf("c"), delta.songIdsToAdd)
assertEquals(emptyList<Int>(), delta.indicesToRemove)
}
@Test
fun `detects a track removed locally by its remote index`() {
val delta = computePlaylistSyncDelta(remoteIds = listOf("a", "b", "c"), localIds = listOf("a", "c"))
assertEquals(emptyList<String>(), delta.songIdsToAdd)
assertEquals(listOf(1), delta.indicesToRemove)
}
@Test
fun `handles a simultaneous add and remove`() {
val delta = computePlaylistSyncDelta(remoteIds = listOf("a", "b", "c"), localIds = listOf("a", "c", "d"))
assertEquals(listOf("d"), delta.songIdsToAdd)
assertEquals(listOf(1), delta.indicesToRemove)
}
@Test
fun `a pure reorder with no set change produces an empty delta`() {
val delta = computePlaylistSyncDelta(remoteIds = listOf("a", "b", "c"), localIds = listOf("c", "a", "b"))
assertEquals(emptyList<String>(), delta.songIdsToAdd)
assertEquals(emptyList<Int>(), delta.indicesToRemove)
}
@Test
fun `removing everything removes every remote index`() {
val delta = computePlaylistSyncDelta(remoteIds = listOf("a", "b", "c"), localIds = emptyList())
assertEquals(emptyList<String>(), delta.songIdsToAdd)
assertEquals(listOf(0, 1, 2), delta.indicesToRemove)
}
@Test
fun `adding to an empty remote playlist adds every local track`() {
val delta = computePlaylistSyncDelta(remoteIds = emptyList(), localIds = listOf("a", "b"))
assertEquals(listOf("a", "b"), delta.songIdsToAdd)
assertEquals(emptyList<Int>(), delta.indicesToRemove)
}
}