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) + } +}