From 4c116eb84fc45771c74d17baa71c2d2b0ecdcb8d Mon Sep 17 00:00:00 2001 From: christopher Date: Thu, 17 Sep 2026 14:59:17 -0400 Subject: [PATCH] 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 --- .../deepwave/DeepwaveApplication.kt | 6 + .../deepwave/data/local/DeepwaveDatabase.kt | 12 +- .../data/local/playlist/PlaylistDao.kt | 68 +++++ .../data/local/playlist/PlaylistEntity.kt | 28 ++ .../local/playlist/PlaylistTrackEntity.kt | 22 ++ .../deepwave/data/model/LibraryModels.kt | 9 + .../deepwave/data/network/SubsonicApi.kt | 32 +++ .../data/network/model/PlaylistModels.kt | 66 +++++ .../deepwave/data/playlist/PlaylistMappers.kt | 63 +++++ .../data/playlist/PlaylistRepository.kt | 144 +++++++++++ .../data/playlist/PlaylistSyncDelta.kt | 25 ++ .../data/playlist/PlaylistSyncWorker.kt | 137 ++++++++++ .../deepwave/di/DatabaseModule.kt | 4 + .../di/PlaylistRepositoryEntryPoint.kt | 13 + .../deepwave/ui/library/AlbumDetailScreen.kt | 13 + .../ui/library/AlbumDetailViewModel.kt | 10 + .../deepwave/ui/library/LibraryScreen.kt | 62 +++++ .../deepwave/ui/navigation/DeepwaveNavHost.kt | 5 + .../deepwave/ui/navigation/MainScreen.kt | 7 +- .../deepwave/ui/navigation/Route.kt | 1 + .../ui/playlist/AddToPlaylistSheet.kt | 103 ++++++++ .../ui/playlist/AddToPlaylistViewModel.kt | 29 +++ .../ui/playlist/CreatePlaylistSheet.kt | 68 +++++ .../ui/playlist/PlaylistDetailScreen.kt | 244 ++++++++++++++++++ .../ui/playlist/PlaylistDetailViewModel.kt | 113 ++++++++ .../deepwave/ui/playlist/PlaylistsScreen.kt | 97 +++++++ .../ui/playlist/PlaylistsViewModel.kt | 43 +++ app/src/main/res/values/strings.xml | 23 ++ .../data/playlist/PlaylistSyncDeltaTest.kt | 68 +++++ 29 files changed, 1510 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistDao.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistEntity.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistTrackEntity.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/PlaylistModels.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistMappers.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistRepository.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncDelta.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncWorker.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/di/PlaylistRepositoryEntryPoint.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/AddToPlaylistSheet.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/AddToPlaylistViewModel.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/CreatePlaylistSheet.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistDetailScreen.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistDetailViewModel.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsScreen.kt create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsViewModel.kt create mode 100644 app/src/test/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncDeltaTest.kt diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt index 9f3999c..4f2aa61 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt @@ -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() } /** diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/DeepwaveDatabase.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/DeepwaveDatabase.kt index 197e519..6994234 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/DeepwaveDatabase.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/DeepwaveDatabase.kt @@ -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 } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistDao.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistDao.kt new file mode 100644 index 0000000..342f989 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistDao.kt @@ -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> + + @Query("SELECT * FROM playlists WHERE localId = :localId") + fun observePlaylist(localId: String): Flow + + @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 + + @Query("SELECT * FROM playlists WHERE syncState = 'SYNCED'") + suspend fun getSyncedPlaylists(): List + + @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> + + @Query("SELECT * FROM playlist_tracks WHERE playlistLocalId = :localId ORDER BY position ASC") + suspend fun getTracks(localId: String): List + + @Insert + suspend fun insertTracks(tracks: List) + + @Query("DELETE FROM playlist_tracks WHERE playlistLocalId = :localId") + suspend fun clearTracks(localId: String) + + @Transaction + suspend fun replaceTracks(localId: String, tracks: List) { + clearTracks(localId) + insertTracks(tracks) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistEntity.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistEntity.kt new file mode 100644 index 0000000..ebc7b9b --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistEntity.kt @@ -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, +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistTrackEntity.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistTrackEntity.kt new file mode 100644 index 0000000..ca452cc --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistTrackEntity.kt @@ -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?, +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/model/LibraryModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/model/LibraryModels.kt index 8cd9ee9..bb08ddd 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/data/model/LibraryModels.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/model/LibraryModels.kt @@ -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, +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt index 6b06177..815ab1c 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/SubsonicApi.kt @@ -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 = 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 = emptyList(), + @Query("songIndexToRemove") songIndexToRemove: List = emptyList(), + ): PingResponse + + @GET("rest/deletePlaylist") + suspend fun deletePlaylist(@Query("id") id: String): PingResponse } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/PlaylistModels.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/PlaylistModels.kt new file mode 100644 index 0000000..c4672f8 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/model/PlaylistModels.kt @@ -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 = 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 = 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, +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistMappers.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistMappers.kt new file mode 100644 index 0000000..a889e20 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistMappers.kt @@ -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, +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistRepository.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistRepository.kt new file mode 100644 index 0000000..983d3c4 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistRepository.kt @@ -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> = + playlistDao.observePlaylistsWithCount().map { list -> list.map { it.toDomain() } } + + fun observePlaylist(localId: String): Flow = combine( + playlistDao.observePlaylist(localId), + playlistDao.observeTracks(localId), + ) { entity, tracks -> entity?.toDomain(trackCount = tracks.size) } + + fun observeTracks(localId: String): Flow> = + playlistDao.observeTracks(localId).map { list -> list.map { it.toDomain() } } + + suspend fun createPlaylist(name: String, initialTracks: List = 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() + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build() + workManager.enqueueUniqueWork(SYNC_WORK_NAME, ExistingWorkPolicy.REPLACE, request) + } + + private fun enqueuePeriodicSync() { + val request = PeriodicWorkRequestBuilder(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" + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncDelta.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncDelta.kt new file mode 100644 index 0000000..7a26950 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncDelta.kt @@ -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, val indicesToRemove: List) + +/** + * 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, localIds: List): 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) +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncWorker.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncWorker.kt new file mode 100644 index 0000000..c0a882e --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncWorker.kt @@ -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 + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/di/DatabaseModule.kt b/app/src/main/java/com/InfernalAquatics/deepwave/di/DatabaseModule.kt index 426d1d5..04f9d7a 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/di/DatabaseModule.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/di/DatabaseModule.kt @@ -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() } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/di/PlaylistRepositoryEntryPoint.kt b/app/src/main/java/com/InfernalAquatics/deepwave/di/PlaylistRepositoryEntryPoint.kt new file mode 100644 index 0000000..3b194ff --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/di/PlaylistRepositoryEntryPoint.kt @@ -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 +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt index 9df7ef5..22ffc9e 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt @@ -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 = { 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) + } } } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt index 6b813b8..8105811 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt @@ -41,6 +41,7 @@ data class AlbumDetailUiState( val songs: List = 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 = 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)) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt new file mode 100644 index 0000000..6733e65 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt @@ -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 = {}) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt index a1506c1..b5a7bb8 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt @@ -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 { @@ -118,6 +120,9 @@ fun DeepwaveNavHost( composable { AlbumDetailScreen(onBack = { navController.popBackStack() }) } + composable { + PlaylistDetailScreen(onBack = { navController.popBackStack() }) + } composable { NowPlayingScreen(onBack = { navController.popBackStack() }) } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt index 39f2318..105cd0d 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt @@ -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 = {}) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt index 97c3fbb..176dda8 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt @@ -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 } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/AddToPlaylistSheet.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/AddToPlaylistSheet.kt new file mode 100644 index 0000000..31a4388 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/AddToPlaylistSheet.kt @@ -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, + 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 = {}) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/AddToPlaylistViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/AddToPlaylistViewModel.kt new file mode 100644 index 0000000..9ab6751 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/AddToPlaylistViewModel.kt @@ -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> = 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)) } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/CreatePlaylistSheet.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/CreatePlaylistSheet.kt new file mode 100644 index 0000000..7d77dbd --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/CreatePlaylistSheet.kt @@ -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 = {}) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistDetailScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistDetailScreen.kt new file mode 100644 index 0000000..dc5e090 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistDetailScreen.kt @@ -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 = { 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) { + 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 = {}, + ) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistDetailViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistDetailViewModel.kt new file mode 100644 index 0000000..6002545 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistDetailViewModel.kt @@ -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 = 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().playlistId + + private val _isConfirmingDelete = MutableStateFlow(false) + private val _isPickingDownloadQuality = MutableStateFlow(false) + + val uiState: StateFlow = 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 = 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 = 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 } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsScreen.kt new file mode 100644 index 0000000..deefab4 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsScreen.kt @@ -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 = {}) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsViewModel.kt new file mode 100644 index 0000000..6f9f600 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsViewModel.kt @@ -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 = emptyList(), val isCreating: Boolean = false) + +@HiltViewModel +class PlaylistsViewModel @Inject constructor( + private val playlistRepository: PlaylistRepository, +) : ViewModel() { + private val _isCreating = MutableStateFlow(false) + + val uiState: StateFlow = 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 } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f82369..c65ae5a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,4 +47,27 @@ Artists Albums Songs + + Playlists + Artists + + New playlist + New playlist + Name + Create + No playlists yet + No tracks yet + Add to playlist + Remove from playlist + Download playlist + Delete playlist + Delete playlist? + This deletes it from the server too, for every device. + Cancel + Delete + + + %d song + %d songs + diff --git a/app/src/test/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncDeltaTest.kt b/app/src/test/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncDeltaTest.kt new file mode 100644 index 0000000..0c8047f --- /dev/null +++ b/app/src/test/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistSyncDeltaTest.kt @@ -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(), delta.songIdsToAdd) + assertEquals(emptyList(), 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(), 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(), 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(), delta.songIdsToAdd) + assertEquals(emptyList(), delta.indicesToRemove) + } + + @Test + fun `removing everything removes every remote index`() { + val delta = computePlaylistSyncDelta(remoteIds = listOf("a", "b", "c"), localIds = emptyList()) + + assertEquals(emptyList(), 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(), delta.indicesToRemove) + } +}