diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index e62859543..a1682ffb7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -299,8 +299,13 @@ constructor( */ fun savePlaylistEdit() { val playlist = _currentPlaylist.value ?: return - val editedPlaylist = (_editedPlaylist.value ?: return).also { _editedPlaylist.value = null } - musicRepository.rewritePlaylist(playlist, editedPlaylist) + val editedPlaylist = _editedPlaylist.value ?: return + viewModelScope.launch { + musicRepository.rewritePlaylist(playlist, editedPlaylist) + // TODO: The user could probably press some kind of button if they were fast enough. + // Think of a better way to handle this state. + _editedPlaylist.value = null + } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 8d7c64f7b..df5e011a3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -116,7 +116,7 @@ interface MusicRepository { * @param name The name of the new [Playlist]. * @param songs The songs to populate the new [Playlist] with. */ - fun createPlaylist(name: String, songs: List) + suspend fun createPlaylist(name: String, songs: List) /** * Rename a [Playlist]. @@ -124,14 +124,14 @@ interface MusicRepository { * @param playlist The [Playlist] to rename. * @param name The name of the new [Playlist]. */ - fun renamePlaylist(playlist: Playlist, name: String) + suspend fun renamePlaylist(playlist: Playlist, name: String) /** * Delete a [Playlist]. * * @param playlist The playlist to delete. */ - fun deletePlaylist(playlist: Playlist) + suspend fun deletePlaylist(playlist: Playlist) /** * Add the given [Song]s to a [Playlist]. @@ -139,7 +139,7 @@ interface MusicRepository { * @param songs The [Song]s to add to the [Playlist]. * @param playlist The [Playlist] to add to. */ - fun addToPlaylist(songs: List, playlist: Playlist) + suspend fun addToPlaylist(songs: List, playlist: Playlist) /** * Update the [Song]s of a [Playlist]. @@ -147,7 +147,7 @@ interface MusicRepository { * @param playlist The [Playlist] to update. * @param songs The new [Song]s to be contained in the [Playlist]. */ - fun rewritePlaylist(playlist: Playlist, songs: List) + suspend fun rewritePlaylist(playlist: Playlist, songs: List) /** * Request that a music loading operation is started by the current [IndexingWorker]. Does @@ -276,7 +276,7 @@ constructor( (deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) } ?: userLibrary?.findPlaylist(uid)) - override fun createPlaylist(name: String, songs: List) { + override suspend fun createPlaylist(name: String, songs: List) { val userLibrary = userLibrary ?: return userLibrary.createPlaylist(name, songs) for (listener in updateListeners) { @@ -285,7 +285,7 @@ constructor( } } - override fun renamePlaylist(playlist: Playlist, name: String) { + override suspend fun renamePlaylist(playlist: Playlist, name: String) { val userLibrary = userLibrary ?: return userLibrary.renamePlaylist(playlist, name) for (listener in updateListeners) { @@ -294,7 +294,7 @@ constructor( } } - override fun deletePlaylist(playlist: Playlist) { + override suspend fun deletePlaylist(playlist: Playlist) { val userLibrary = userLibrary ?: return userLibrary.deletePlaylist(playlist) for (listener in updateListeners) { @@ -303,7 +303,7 @@ constructor( } } - override fun addToPlaylist(songs: List, playlist: Playlist) { + override suspend fun addToPlaylist(songs: List, playlist: Playlist) { val userLibrary = userLibrary ?: return userLibrary.addToPlaylist(playlist, songs) for (listener in updateListeners) { @@ -312,7 +312,7 @@ constructor( } } - override fun rewritePlaylist(playlist: Playlist, songs: List) { + override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { val userLibrary = userLibrary ?: return userLibrary.rewritePlaylist(playlist, songs) for (listener in updateListeners) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 873ed851e..d207bd135 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -19,10 +19,13 @@ package org.oxycblt.auxio.music import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent @@ -110,7 +113,7 @@ constructor( */ fun createPlaylist(name: String? = null, songs: List = listOf()) { if (name != null) { - musicRepository.createPlaylist(name, songs) + viewModelScope.launch(Dispatchers.IO) { musicRepository.createPlaylist(name, songs) } } else { _newPlaylistSongs.put(songs) } @@ -124,7 +127,7 @@ constructor( */ fun renamePlaylist(playlist: Playlist, name: String? = null) { if (name != null) { - musicRepository.renamePlaylist(playlist, name) + viewModelScope.launch(Dispatchers.IO) { musicRepository.renamePlaylist(playlist, name) } } else { _playlistToRename.put(playlist) } @@ -139,7 +142,7 @@ constructor( */ fun deletePlaylist(playlist: Playlist, rude: Boolean = false) { if (rude) { - musicRepository.deletePlaylist(playlist) + viewModelScope.launch(Dispatchers.IO) { musicRepository.deletePlaylist(playlist) } } else { _playlistToDelete.put(playlist) } @@ -193,7 +196,7 @@ constructor( */ fun addToPlaylist(songs: List, playlist: Playlist? = null) { if (playlist != null) { - musicRepository.addToPlaylist(songs, playlist) + viewModelScope.launch(Dispatchers.IO) { musicRepository.addToPlaylist(songs, playlist) } } else { _songsToAdd.put(songs) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt index 6f56be360..51c15d1bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/RawPlaylist.kt @@ -21,6 +21,10 @@ package org.oxycblt.auxio.music.user import androidx.room.* import org.oxycblt.auxio.music.Music +/** + * Raw playlist information persisted to [UserMusicDatabase]. + * @author Alexander Capehart (OxygenCobalt) + */ data class RawPlaylist( @Embedded val playlistInfo: PlaylistInfo, @Relation( @@ -30,12 +34,26 @@ data class RawPlaylist( val songs: List ) +/** + * UID and name information corresponding to a [RawPlaylist] entry. + * @author Alexander Capehart (OxygenCobalt) + */ @Entity data class PlaylistInfo(@PrimaryKey val playlistUid: Music.UID, val name: String) +/** + * Song information corresponding to a [RawPlaylist] entry. + * @author Alexander Capehart (OxygenCobalt) + */ @Entity data class PlaylistSong(@PrimaryKey val songUid: Music.UID) -@Entity(primaryKeys = ["playlistUid", "songUid"]) + +/** + * Links individual songs to a playlist entry. + * @author Alexander Capehart (OxygenCobalt) + */ +@Entity data class PlaylistSongCrossRef( + @PrimaryKey(autoGenerate = true) val id: Long = 0, val playlistUid: Music.UID, - @ColumnInfo(index = true) val songUid: Music.UID + val songUid: Music.UID ) diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index 7962533de..fc64f5918 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -57,11 +57,12 @@ interface UserLibrary { /** * Create a new [UserLibrary]. * - * @param deviceLibrary Asynchronously populated [DeviceLibrary] that can be obtained later. - * This allows database information to be read before the actual instance is constructed. + * @param deviceLibraryChannel Asynchronously populated [DeviceLibrary] that can be obtained + * later. This allows database information to be read before the actual instance is + * constructed. * @return A new [MutableUserLibrary] with the required implementation. */ - suspend fun read(deviceLibrary: Channel): MutableUserLibrary + suspend fun read(deviceLibraryChannel: Channel): MutableUserLibrary } } @@ -78,7 +79,7 @@ interface MutableUserLibrary : UserLibrary { * @param name The name of the [Playlist]. * @param songs The songs to place in the [Playlist]. */ - fun createPlaylist(name: String, songs: List) + suspend fun createPlaylist(name: String, songs: List) /** * Rename a [Playlist]. @@ -86,21 +87,21 @@ interface MutableUserLibrary : UserLibrary { * @param playlist The [Playlist] to rename. * @param name The name of the new [Playlist]. */ - fun renamePlaylist(playlist: Playlist, name: String) + suspend fun renamePlaylist(playlist: Playlist, name: String) /** * Delete a [Playlist]. * * @param playlist The playlist to delete. */ - fun deletePlaylist(playlist: Playlist) + suspend fun deletePlaylist(playlist: Playlist) /** * Add [Song]s to a [Playlist]. * * @param playlist The [Playlist] to add to. Must currently exist. */ - fun addToPlaylist(playlist: Playlist, songs: List) + suspend fun addToPlaylist(playlist: Playlist, songs: List) /** * Update the [Song]s of a [Playlist]. @@ -108,23 +109,32 @@ interface MutableUserLibrary : UserLibrary { * @param playlist The [Playlist] to update. * @param songs The new [Song]s to be contained in the [Playlist]. */ - fun rewritePlaylist(playlist: Playlist, songs: List) + suspend fun rewritePlaylist(playlist: Playlist, songs: List) } class UserLibraryFactoryImpl @Inject constructor(private val playlistDao: PlaylistDao, private val musicSettings: MusicSettings) : UserLibrary.Factory { - override suspend fun read(deviceLibrary: Channel): MutableUserLibrary = - UserLibraryImpl(playlistDao, deviceLibrary.receive(), musicSettings) + override suspend fun read(deviceLibraryChannel: Channel): MutableUserLibrary { + // While were waiting for the library, read our playlists out. + val rawPlaylists = playlistDao.readRawPlaylists() + val deviceLibrary = deviceLibraryChannel.receive() + // Convert the database playlist information to actual usable playlists. + val playlistMap = mutableMapOf() + for (rawPlaylist in rawPlaylists) { + val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, musicSettings) + playlistMap[playlistImpl.uid] = playlistImpl + } + return UserLibraryImpl(playlistDao, playlistMap, musicSettings) + } } private class UserLibraryImpl( private val playlistDao: PlaylistDao, - private val deviceLibrary: DeviceLibrary, + private val playlistMap: MutableMap, private val musicSettings: MusicSettings ) : MutableUserLibrary { - private val playlistMap = mutableMapOf() override val playlists: List get() = playlistMap.values.toList() @@ -132,35 +142,41 @@ private class UserLibraryImpl( override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } - @Synchronized - override fun createPlaylist(name: String, songs: List) { + override suspend fun createPlaylist(name: String, songs: List) { val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) - playlistMap[playlistImpl.uid] = playlistImpl + synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } + val rawPlaylist = + RawPlaylist( + PlaylistInfo(playlistImpl.uid, playlistImpl.name.raw), + playlistImpl.songs.map { PlaylistSong(it.uid) }) + playlistDao.insertPlaylist(rawPlaylist) } - @Synchronized - override fun renamePlaylist(playlist: Playlist, name: String) { + override suspend fun renamePlaylist(playlist: Playlist, name: String) { val playlistImpl = requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } - playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) + synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(name, musicSettings) } + playlistDao.replacePlaylistInfo(PlaylistInfo(playlist.uid, name)) } - @Synchronized - override fun deletePlaylist(playlist: Playlist) { - requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" } + override suspend fun deletePlaylist(playlist: Playlist) { + synchronized(this) { + requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" } + } + playlistDao.deletePlaylist(playlist.uid) } - @Synchronized - override fun addToPlaylist(playlist: Playlist, songs: List) { + override suspend fun addToPlaylist(playlist: Playlist, songs: List) { val playlistImpl = requireNotNull(playlistMap[playlist.uid]) { "Cannot add to invalid playlist" } - playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } + synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit { addAll(songs) } } + playlistDao.insertPlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) } - @Synchronized - override fun rewritePlaylist(playlist: Playlist, songs: List) { + override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { val playlistImpl = requireNotNull(playlistMap[playlist.uid]) { "Cannot rewrite invalid playlist" } - playlistMap[playlist.uid] = playlistImpl.edit(songs) + synchronized(this) { playlistMap[playlist.uid] = playlistImpl.edit(songs) } + playlistDao.replacePlaylistSongs(playlist.uid, songs.map { PlaylistSong(it.uid) }) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt index 618babd4d..10e55c5bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt @@ -30,7 +30,7 @@ import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) interface UserModule { - @Binds fun userLibaryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory + @Binds fun userLibraryFactory(factory: UserLibraryFactoryImpl): UserLibrary.Factory } @Module diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt index 361d4f85f..d356d9721 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt @@ -21,16 +21,112 @@ package org.oxycblt.auxio.music.user import androidx.room.* import org.oxycblt.auxio.music.Music +/** + * Allows persistence of all user-created music information. + * @author Alexander Capehart (OxygenCobalt) + */ @Database( entities = [PlaylistInfo::class, PlaylistSong::class, PlaylistSongCrossRef::class], - version = 28, + version = 30, exportSchema = false) @TypeConverters(Music.UID.TypeConverters::class) abstract class UserMusicDatabase : RoomDatabase() { abstract fun playlistDao(): PlaylistDao } +// TODO: Handle playlist defragmentation? I really don't want dead songs to accumulate in this +// database. + +/** + * The DAO for persisted playlist information. + * @author Alexander Capehart (OxygenCobalt) + */ @Dao interface PlaylistDao { - @Transaction @Query("SELECT * FROM PlaylistInfo") fun readRawPlaylists(): List + /** + * Read out all playlists stored in the database. + * @return A list of [RawPlaylist] representing each playlist stored. + */ + @Transaction + @Query("SELECT * FROM PlaylistInfo") + suspend fun readRawPlaylists(): List + + /** + * Create a new playlist. + * @param rawPlaylist The [RawPlaylist] to create. + */ + @Transaction + suspend fun insertPlaylist(rawPlaylist: RawPlaylist) { + insertInfo(rawPlaylist.playlistInfo) + insertSongs(rawPlaylist.songs) + insertRefs( + rawPlaylist.songs.map { + PlaylistSongCrossRef( + playlistUid = rawPlaylist.playlistInfo.playlistUid, songUid = it.songUid) + }) + } + + /** + * Replace the currently-stored [PlaylistInfo] for a playlist entry. + * @param playlistInfo The new [PlaylistInfo] to store. + */ + @Transaction + suspend fun replacePlaylistInfo(playlistInfo: PlaylistInfo) { + deleteInfo(playlistInfo.playlistUid) + insertInfo(playlistInfo) + } + + /** + * Delete a playlist entry's [PlaylistInfo] and [PlaylistSong]. + * @param playlistUid The [Music.UID] of the playlist to delete. + */ + @Transaction + suspend fun deletePlaylist(playlistUid: Music.UID) { + deleteInfo(playlistUid) + deleteRefs(playlistUid) + } + + /** + * Insert new song entries into a playlist. + * @param playlistUid The [Music.UID] of the playlist to insert into. + * @param songs The [PlaylistSong] representing each song to put into the playlist. + */ + @Transaction + suspend fun insertPlaylistSongs(playlistUid: Music.UID, songs: List) { + insertSongs(songs) + insertRefs( + songs.map { PlaylistSongCrossRef(playlistUid = playlistUid, songUid = it.songUid) }) + } + + /** + * Replace the currently-stored [Song]s of the current playlist entry. + * @param playlistUid The [Music.UID] of the playlist to update. + * @param songs The [PlaylistSong] representing the new list of songs to be placed in the playlist. + */ + @Transaction + suspend fun replacePlaylistSongs(playlistUid: Music.UID, songs: List) { + deleteRefs(playlistUid) + insertSongs(songs) + insertRefs( + songs.map { PlaylistSongCrossRef(playlistUid = playlistUid, songUid = it.songUid) }) + } + + /** Internal, do not use. */ + @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertInfo(info: PlaylistInfo) + + /** Internal, do not use. */ + @Query("DELETE FROM PlaylistInfo where playlistUid = :playlistUid") + suspend fun deleteInfo(playlistUid: Music.UID) + + /** Internal, do not use. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSongs(songs: List) + + /** Internal, do not use. */ + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertRefs(refs: List) + + /** Internal, do not use. */ + @Query("DELETE FROM PlaylistSongCrossRef where playlistUid = :playlistUid") + suspend fun deleteRefs(playlistUid: Music.UID) }