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:
co-authored by
Claude Sonnet 5
parent
a89e81983e
commit
4c116eb84f
@@ -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,
|
||||
)
|
||||
+22
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+113
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+68
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user