diff --git a/app/schemas/me.vanpetegem.accentor.data.AccentorDatabase/12.json b/app/schemas/me.vanpetegem.accentor.data.AccentorDatabase/12.json new file mode 100644 index 00000000..304d4a3d --- /dev/null +++ b/app/schemas/me.vanpetegem.accentor.data.AccentorDatabase/12.json @@ -0,0 +1,726 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "9c3a567c1a3518b26420f35d7e8ab2f7", + "entities": [ + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `permission` INTEGER NOT NULL, `fetched_at` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permission", + "columnName": "permission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `normalized_title` TEXT NOT NULL, `release` TEXT NOT NULL, `review_comment` TEXT, `edition` TEXT, `edition_description` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `image` TEXT, `image_500` TEXT, `image_250` TEXT, `image_100` TEXT, `image_type` TEXT, `fetched_at` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedTitle", + "columnName": "normalized_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "release", + "columnName": "release", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reviewComment", + "columnName": "review_comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "edition", + "columnName": "edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "editionDescription", + "columnName": "edition_description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image500", + "columnName": "image_500", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image250", + "columnName": "image_250", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image100", + "columnName": "image_100", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageType", + "columnName": "image_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`album_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `normalized_name` TEXT NOT NULL, `order` INTEGER NOT NULL, `separator` TEXT, PRIMARY KEY(`album_id`, `artist_id`, `name`))", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedName", + "columnName": "normalized_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "separator", + "columnName": "separator", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "album_id", + "artist_id", + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album_labels", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`album_id` INTEGER NOT NULL, `label_id` INTEGER NOT NULL, `catalogue_number` TEXT, PRIMARY KEY(`album_id`, `label_id`))", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "labelId", + "columnName": "label_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "catalogueNumber", + "columnName": "catalogue_number", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "album_id", + "label_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `normalized_name` TEXT NOT NULL, `review_comment` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `image` TEXT, `image_500` TEXT, `image_250` TEXT, `image_100` TEXT, `image_type` TEXT, `fetched_at` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedName", + "columnName": "normalized_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reviewComment", + "columnName": "review_comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image500", + "columnName": "image_500", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image250", + "columnName": "image_250", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image100", + "columnName": "image_100", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "imageType", + "columnName": "image_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "codec_conversions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `ffmpeg_params` TEXT NOT NULL, `resulting_codec_id` INTEGER NOT NULL, `fetched_at` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ffmpegParams", + "columnName": "ffmpeg_params", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "resultingCodecId", + "columnName": "resulting_codec_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "plays", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `played_at` TEXT NOT NULL, `track_id` INTEGER NOT NULL, `user_id` INTEGER NOT NULL, `fetched_at` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playedAt", + "columnName": "played_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `user_id` INTEGER NOT NULL, `playlist_type` INTEGER NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `access` INTEGER NOT NULL, `fetched_at` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistType", + "columnName": "playlist_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "access", + "columnName": "access", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `item_id` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `item_id`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "item_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "playlist_id", + "item_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tracks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `normalized_title` TEXT NOT NULL, `number` INTEGER NOT NULL, `album_id` INTEGER NOT NULL, `review_comment` TEXT, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, `codec_id` INTEGER, `length` INTEGER, `bitrate` INTEGER, `location_id` INTEGER, `fetched_at` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedTitle", + "columnName": "normalized_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reviewComment", + "columnName": "review_comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecId", + "columnName": "codec_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "length", + "columnName": "length", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "locationId", + "columnName": "location_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "track_artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_id` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `normalized_name` TEXT NOT NULL, `role` INTEGER NOT NULL, `order` INTEGER NOT NULL, `hidden` INTEGER NOT NULL, PRIMARY KEY(`track_id`, `artist_id`, `name`, `role`))", + "fields": [ + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedName", + "columnName": "normalized_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "track_id", + "artist_id", + "name", + "role" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "track_genres", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`track_id` INTEGER NOT NULL, `genre_id` INTEGER NOT NULL, PRIMARY KEY(`track_id`, `genre_id`))", + "fields": [ + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "genreId", + "columnName": "genre_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "track_id", + "genre_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "unreported_plays", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `track_id` INTEGER NOT NULL, `played_at` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackId", + "columnName": "track_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playedAt", + "columnName": "played_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9c3a567c1a3518b26420f35d7e8ab2f7')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/me/vanpetegem/accentor/api/playlist/Playlist.kt b/app/src/main/java/me/vanpetegem/accentor/api/playlist/Playlist.kt new file mode 100644 index 00000000..60ff76cb --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/api/playlist/Playlist.kt @@ -0,0 +1,35 @@ +package me.vanpetegem.accentor.api.playlist + +import com.github.kittinunf.fuel.httpGet +import me.vanpetegem.accentor.api.util.retry +import me.vanpetegem.accentor.data.authentication.AuthenticationData +import me.vanpetegem.accentor.data.playlists.ApiPlaylist +import me.vanpetegem.accentor.util.Result +import me.vanpetegem.accentor.util.responseObject + +fun index(server: String, authenticationData: AuthenticationData): Sequence>> { + var page = 1 + + fun doFetch(): Result>? { + return retry(5) { + "$server/api/playlists".httpGet(listOf(Pair("page", page))) + .set("Accept", "application/json") + .set("X-Secret", authenticationData.secret) + .set("X-Device-Id", authenticationData.deviceId) + .responseObject>().third + .fold( + { a: List -> + if (a.isEmpty()) { + null + } else { + page++ + Result.Success(a) + } + }, + { e: Throwable -> Result.Error(Exception("Error getting playlists", e)) }, + ) + } + } + + return generateSequence { doFetch() } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/data/AccentorDatabase.kt b/app/src/main/java/me/vanpetegem/accentor/data/AccentorDatabase.kt index 52ed5098..8db49628 100644 --- a/app/src/main/java/me/vanpetegem/accentor/data/AccentorDatabase.kt +++ b/app/src/main/java/me/vanpetegem/accentor/data/AccentorDatabase.kt @@ -22,6 +22,9 @@ import me.vanpetegem.accentor.data.artists.ArtistDao import me.vanpetegem.accentor.data.artists.DbArtist import me.vanpetegem.accentor.data.codecconversions.CodecConversionDao import me.vanpetegem.accentor.data.codecconversions.DbCodecConversion +import me.vanpetegem.accentor.data.playlists.DbPlaylist +import me.vanpetegem.accentor.data.playlists.DbPlaylistItem +import me.vanpetegem.accentor.data.playlists.PlaylistDao import me.vanpetegem.accentor.data.plays.DbPlay import me.vanpetegem.accentor.data.plays.PlayDao import me.vanpetegem.accentor.data.plays.UnreportedPlay @@ -44,17 +47,20 @@ import me.vanpetegem.accentor.util.RoomTypeConverters DbArtist::class, DbCodecConversion::class, DbPlay::class, + DbPlaylist::class, + DbPlaylistItem::class, DbTrack::class, DbTrackArtist::class, DbTrackGenre::class, UnreportedPlay::class, ], - version = 11 + version = 12 ) abstract class AccentorDatabase : RoomDatabase() { abstract fun albumDao(): AlbumDao abstract fun artistDao(): ArtistDao abstract fun codecConversionDao(): CodecConversionDao + abstract fun playlistDao(): PlaylistDao abstract fun playDao(): PlayDao abstract fun trackDao(): TrackDao abstract fun userDao(): UserDao @@ -259,6 +265,42 @@ internal object DatabaseModule { } } }) + .addMigrations(object : Migration(11, 12) { + override fun migrate(database: SupportSQLiteDatabase) { + database.beginTransaction() + try { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `playlists` ( + `id` INTEGER NOT NULL, + `name` TEXT NOT NULL, + `description` TEXT, + `user_id` INTEGER NOT NULL, + `playlist_type` INTEGER NOT NULL, + `created_at` TEXT NOT NULL, + `updated_at` TEXT NOT NULL, + `access` INTEGER NOT NULL, + `fetched_at` TEXT NOT NULL, + PRIMARY KEY(`id`) + ) + """ + ) + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `playlist_items` ( + `playlist_id` INTEGER NOT NULL, + `item_id` INTEGER NOT NULL, + `order` INTEGER NOT NULL, + PRIMARY KEY(`playlist_id`, `item_id`) + ) + """ + ) + database.setTransactionSuccessful() + } finally { + database.endTransaction() + } + } + }) .build() } @@ -269,6 +311,8 @@ internal object DatabaseModule { @Provides fun provideCodecConversionDao(database: AccentorDatabase): CodecConversionDao = database.codecConversionDao() @Provides + fun providePlaylistDao(database: AccentorDatabase): PlaylistDao = database.playlistDao() + @Provides fun providePlayDao(database: AccentorDatabase): PlayDao = database.playDao() @Provides fun provideTrackDao(database: AccentorDatabase): TrackDao = database.trackDao() diff --git a/app/src/main/java/me/vanpetegem/accentor/data/albums/AlbumDao.kt b/app/src/main/java/me/vanpetegem/accentor/data/albums/AlbumDao.kt index 93be6440..c969121c 100644 --- a/app/src/main/java/me/vanpetegem/accentor/data/albums/AlbumDao.kt +++ b/app/src/main/java/me/vanpetegem/accentor/data/albums/AlbumDao.kt @@ -32,6 +32,15 @@ abstract class AlbumDao { } } + open fun getByIds(ids: List): List { + val albums = getDbAlbumsByIds(ids) + val albumsByIds = SparseArray() + albums.forEach { albumsByIds.put(it.id, it) } + val albumArtists = getAlbumArtistsByAlbumIdWhereAlbumIds(ids) + val albumLabels = getAlbumLabelsByAlbumIdWhereAlbumIds(ids) + return ids.map { Album.fromDb(albumsByIds.get(it), albumLabels.get(it, ArrayList()), albumArtists.get(it, ArrayList())) } + } + open fun findByIds(ids: List): LiveData> = switchMap(findDbAlbumsByIds(ids)) { albums -> switchMap(albumArtistsByAlbumIdWhereAlbumIds(ids)) { albumArtists -> map(albumLabelsByAlbumIdWhereAlbumIds(ids)) { albumLabels -> @@ -88,6 +97,9 @@ abstract class AlbumDao { @Query("SELECT * FROM albums WHERE id IN (:ids)") protected abstract fun findDbAlbumsByIds(ids: List): LiveData> + @Query("SELECT * FROM albums WHERE id IN (:ids)") + protected abstract fun getDbAlbumsByIds(ids: List): List + @Query( """ SELECT * FROM albums WHERE release LIKE '%' || :day || '%' @@ -124,7 +136,7 @@ abstract class AlbumDao { } protected open fun albumLabelsByAlbumIdWhereAlbumIds(ids: List): LiveData>> = - map(getAllAlbumLabelsWhereAlbumIds(ids)) { + map(findAllAlbumLabelsWhereAlbumIds(ids)) { val map = SparseArray>() for (al in it) { val l = map.get(al.albumId, ArrayList()) @@ -134,6 +146,17 @@ abstract class AlbumDao { return@map map } + protected open fun getAlbumLabelsByAlbumIdWhereAlbumIds(ids: List): SparseArray> { + val albumLabels = getAllAlbumLabelsWhereAlbumIds(ids) + val map = SparseArray>() + for (al in albumLabels) { + val l = map.get(al.albumId, ArrayList()) + l.add(AlbumLabel(al.labelId, al.catalogueNumber)) + map.put(al.albumId, l) + } + return map + } + protected open fun albumArtistsByAlbumId(): LiveData>> = map(getAllAlbumArtists()) { val map = SparseArray>() @@ -146,7 +169,7 @@ abstract class AlbumDao { } protected open fun albumArtistsByAlbumIdWhereAlbumIds(ids: List): LiveData>> = - map(getAllAlbumArtistsWhereAlbumIds(ids)) { + map(findAllAlbumArtistsWhereAlbumIds(ids)) { val map = SparseArray>() for (aa in it) { val l = map.get(aa.albumId, ArrayList()) @@ -156,6 +179,17 @@ abstract class AlbumDao { return@map map } + protected open fun getAlbumArtistsByAlbumIdWhereAlbumIds(ids: List): SparseArray> { + val albumArtists = getAllAlbumArtistsWhereAlbumIds(ids) + val map = SparseArray>() + for (aa in albumArtists) { + val l = map.get(aa.albumId, ArrayList()) + l.add(AlbumArtist(aa.artistId, aa.name, aa.normalizedName, aa.order, aa.separator)) + map.put(aa.albumId, l) + } + return map + } + @Transaction open fun upsertAll(albums: List) { albums.forEach { album: Album -> @@ -207,13 +241,19 @@ abstract class AlbumDao { protected abstract fun getAllAlbumArtists(): LiveData> @Query("SELECT * FROM album_artists WHERE album_id IN (:ids)") - protected abstract fun getAllAlbumArtistsWhereAlbumIds(ids: List): LiveData> + protected abstract fun findAllAlbumArtistsWhereAlbumIds(ids: List): LiveData> + + @Query("SELECT * FROM album_artists WHERE album_id IN (:ids)") + protected abstract fun getAllAlbumArtistsWhereAlbumIds(ids: List): List @Query("SELECT * FROM album_labels") protected abstract fun getAllAlbumLabels(): LiveData> @Query("SELECT * FROM album_labels WHERE album_id IN (:ids)") - protected abstract fun getAllAlbumLabelsWhereAlbumIds(ids: List): LiveData> + protected abstract fun findAllAlbumLabelsWhereAlbumIds(ids: List): LiveData> + + @Query("SELECT * FROM album_labels WHERE album_id IN (:ids)") + protected abstract fun getAllAlbumLabelsWhereAlbumIds(ids: List): List @Insert(onConflict = OnConflictStrategy.REPLACE) protected abstract fun upsert(album: DbAlbum) diff --git a/app/src/main/java/me/vanpetegem/accentor/data/albums/AlbumRepository.kt b/app/src/main/java/me/vanpetegem/accentor/data/albums/AlbumRepository.kt index d60f599c..0898e799 100644 --- a/app/src/main/java/me/vanpetegem/accentor/data/albums/AlbumRepository.kt +++ b/app/src/main/java/me/vanpetegem/accentor/data/albums/AlbumRepository.kt @@ -41,6 +41,7 @@ class AlbumRepository @Inject constructor( fun findById(id: Int): LiveData = albumDao.findById(id) fun getById(id: Int): Album? = albumDao.getAlbumById(id) + fun getByIds(ids: List): List = albumDao.getByIds(ids) fun findByIds(ids: List): LiveData> = albumDao.findByIds(ids) diff --git a/app/src/main/java/me/vanpetegem/accentor/data/playlists/Access.kt b/app/src/main/java/me/vanpetegem/accentor/data/playlists/Access.kt new file mode 100644 index 00000000..a6fb92ab --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/data/playlists/Access.kt @@ -0,0 +1,7 @@ +package me.vanpetegem.accentor.data.playlists + +enum class Access { + SHARED, + PERSONAL, + SECRET, +} diff --git a/app/src/main/java/me/vanpetegem/accentor/data/playlists/ApiPlaylist.kt b/app/src/main/java/me/vanpetegem/accentor/data/playlists/ApiPlaylist.kt new file mode 100644 index 00000000..c62d34fb --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/data/playlists/ApiPlaylist.kt @@ -0,0 +1,15 @@ +package me.vanpetegem.accentor.data.playlists + +import java.time.Instant + +data class ApiPlaylist( + val id: Int, + val name: String, + val description: String?, + val userId: Int, + val playlistType: PlaylistType, + val createdAt: Instant, + val updatedAt: Instant, + val itemIds: List, + val access: Access, +) diff --git a/app/src/main/java/me/vanpetegem/accentor/data/playlists/DbPlaylist.kt b/app/src/main/java/me/vanpetegem/accentor/data/playlists/DbPlaylist.kt new file mode 100644 index 00000000..0991f4ce --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/data/playlists/DbPlaylist.kt @@ -0,0 +1,39 @@ +package me.vanpetegem.accentor.data.playlists + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.Instant + +@Entity(tableName = "playlists") +data class DbPlaylist( + @PrimaryKey + @ColumnInfo(name = "id") + val id: Int, + @ColumnInfo(name = "name") + val name: String, + @ColumnInfo(name = "description") + val description: String?, + @ColumnInfo(name = "user_id") + val userId: Int, + @ColumnInfo(name = "playlist_type") + val playlistType: PlaylistType, + @ColumnInfo(name = "created_at") + val createdAt: Instant, + @ColumnInfo(name = "updated_at") + val updatedAt: Instant, + @ColumnInfo(name = "access") + val access: Access, + @ColumnInfo(name = "fetched_at") + val fetchedAt: Instant, +) + +@Entity(tableName = "playlist_items", primaryKeys = ["playlist_id", "item_id"]) +data class DbPlaylistItem( + @ColumnInfo(name = "playlist_id") + val playlistId: Int, + @ColumnInfo(name = "item_id") + val itemId: Int, + @ColumnInfo(name = "order") + val order: Int, +) diff --git a/app/src/main/java/me/vanpetegem/accentor/data/playlists/Playlist.kt b/app/src/main/java/me/vanpetegem/accentor/data/playlists/Playlist.kt new file mode 100644 index 00000000..4dc739c0 --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/data/playlists/Playlist.kt @@ -0,0 +1,82 @@ +package me.vanpetegem.accentor.data.playlists + +import android.util.SparseArray +import java.time.Instant +import me.vanpetegem.accentor.data.albums.Album +import me.vanpetegem.accentor.data.albums.AlbumRepository +import me.vanpetegem.accentor.data.tracks.Track +import me.vanpetegem.accentor.data.tracks.TrackRepository + +data class Playlist( + val id: Int, + val name: String, + val description: String?, + val userId: Int, + val playlistType: PlaylistType, + val createdAt: Instant, + val updatedAt: Instant, + val itemIds: List, + val access: Access, + val fetchedAt: Instant, +) { + companion object { + fun fromDb(p: DbPlaylist, playlistItems: List) = + Playlist( + p.id, + p.name, + p.description, + p.userId, + p.playlistType, + p.createdAt, + p.updatedAt, + playlistItems, + p.access, + p.fetchedAt, + ) + + fun fromApi(p: ApiPlaylist, fetchTime: Instant) = + Playlist( + p.id, + p.name, + p.description, + p.userId, + p.playlistType, + p.createdAt, + p.updatedAt, + p.itemIds, + p.access, + fetchTime, + ) + } + + fun toTrackAlbumPairs(trackRepository: TrackRepository, albumRepository: AlbumRepository): List> { + return when (playlistType) { + PlaylistType.TRACK -> { + val albumMap = SparseArray() + val tracks = trackRepository.getByIds(itemIds) + tracks.forEach { + if (albumMap.indexOfKey(it.albumId) < 0) { + albumMap.put(it.albumId, albumRepository.getById(it.albumId)!!) + } + } + tracks.map { Pair(it, albumMap.get(it.albumId)) } + } + PlaylistType.ALBUM -> albumRepository.getByIds(itemIds).flatMap { a -> + trackRepository.getByAlbum(a).map { t -> Pair(t, a) } + } + PlaylistType.ARTIST -> { + val albumMap = SparseArray() + itemIds.flatMap { id -> + val tracks = trackRepository.getByArtistId(id).toMutableList() + tracks.forEach { + if (albumMap.indexOfKey(it.albumId) < 0) { + albumMap.put(it.albumId, albumRepository.getById(it.albumId)!!) + } + } + tracks.sortWith({ t1, t2 -> t1.compareAlphabetically(t2, albumMap) }) + tracks.map { Pair(it, albumMap.get(it.albumId)) } + } + } + } + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/data/playlists/PlaylistDao.kt b/app/src/main/java/me/vanpetegem/accentor/data/playlists/PlaylistDao.kt new file mode 100644 index 00000000..4e03d731 --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/data/playlists/PlaylistDao.kt @@ -0,0 +1,95 @@ +package me.vanpetegem.accentor.data.playlists + +import android.util.SparseArray +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations.map +import androidx.lifecycle.Transformations.switchMap +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import java.time.Instant + +@Dao +abstract class PlaylistDao { + + open fun getAll(): LiveData> = switchMap(getAllDbPlaylists()) { playlists -> + map(playlistItemsByPlaylistId()) { playlistItems -> + playlists.map { p -> Playlist.fromDb(p, playlistItems.get(p.id, ArrayList())) } + } + } + + protected open fun playlistItemsByPlaylistId(): LiveData>> = + map(getAllPlaylistItems()) { + val mapping = SparseArray>() + for (pi in it) { + val l = mapping.get(pi.playlistId, ArrayList()) + l.add(pi.itemId) + mapping.put(pi.playlistId, l) + } + return@map mapping + } + + @Transaction + open fun upsertAll(playlists: List) { + playlists.forEach { playlist: Playlist -> + upsert( + DbPlaylist( + playlist.id, + playlist.name, + playlist.description, + playlist.userId, + playlist.playlistType, + playlist.createdAt, + playlist.updatedAt, + playlist.access, + playlist.fetchedAt + ) + ) + deletePlaylistItemsById(playlist.id) + for (i in 0 until playlist.itemIds.size) { + insert(DbPlaylistItem(playlist.id, playlist.itemIds[i], i)) + } + } + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract fun upsert(playlist: DbPlaylist) + + @Insert + protected abstract fun insert(playlistItem: DbPlaylistItem) + + @Transaction + open fun deleteFetchedBefore(time: Instant) { + deletePlaylistsFetchedBefore(time) + deleteUnusedPlaylistItems() + } + + @Query("SELECT * FROM playlists ORDER BY name ASC, id ASC") + protected abstract fun getAllDbPlaylists(): LiveData> + + @Query("SELECT * FROM playlist_items ORDER BY `order` ASC") + protected abstract fun getAllPlaylistItems(): LiveData> + + @Query("DELETE FROM playlists WHERE fetched_at < :time") + protected abstract fun deletePlaylistsFetchedBefore(time: Instant) + + @Query("DELETE FROM playlist_items WHERE playlist_id NOT IN (SELECT id FROM playlists)") + protected abstract fun deleteUnusedPlaylistItems() + + @Query("DELETE FROM playlist_items WHERE playlist_id = :id") + protected abstract fun deletePlaylistItemsById(id: Int) + + @Query("DELETE FROM playlists") + protected abstract fun deleteAllPlaylists() + + @Query("DELETE FROM playlist_items") + protected abstract fun deleteAllPlaylistItems() + + @Transaction + open fun deleteAll() { + deleteAllPlaylists() + deleteAllPlaylistItems() + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/data/playlists/PlaylistRepository.kt b/app/src/main/java/me/vanpetegem/accentor/data/playlists/PlaylistRepository.kt new file mode 100644 index 00000000..222e1883 --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/data/playlists/PlaylistRepository.kt @@ -0,0 +1,56 @@ +package me.vanpetegem.accentor.data.playlists + +import android.util.SparseArray +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations.map +import dagger.Reusable +import java.time.Instant +import javax.inject.Inject +import me.vanpetegem.accentor.api.playlist.index +import me.vanpetegem.accentor.data.authentication.AuthenticationRepository +import me.vanpetegem.accentor.util.Result + +@Reusable +class PlaylistRepository @Inject constructor( + private val playlistDao: PlaylistDao, + private val authenticationRepository: AuthenticationRepository, +) { + val allPlaylists: LiveData> = playlistDao.getAll() + val allPlaylistsById: LiveData> = map(allPlaylists) { + val map = SparseArray() + it.forEach { p -> map.put(p.id, p) } + map + } + + suspend fun refresh(handler: suspend (Result) -> Unit) { + val fetchStart = Instant.now() + + var toUpsert = ArrayList() + var count = 0 + for (result in index(authenticationRepository.server.value!!, authenticationRepository.authData.value!!)) { + when (result) { + is Result.Success -> { + val fetchTime = Instant.now() + toUpsert.addAll(result.data.map { Playlist.fromApi(it, fetchTime) }) + count += 1 + if (count >= 5) { + playlistDao.upsertAll(toUpsert) + toUpsert.clear() + count = 0 + } + } + is Result.Error -> { + handler(Result.Error(result.exception)) + return + } + } + } + playlistDao.upsertAll(toUpsert) + playlistDao.deleteFetchedBefore(fetchStart) + handler(Result.Success(Unit)) + } + + suspend fun clear() { + playlistDao.deleteAll() + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/data/playlists/PlaylistType.kt b/app/src/main/java/me/vanpetegem/accentor/data/playlists/PlaylistType.kt new file mode 100644 index 00000000..3cf7c9ca --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/data/playlists/PlaylistType.kt @@ -0,0 +1,7 @@ +package me.vanpetegem.accentor.data.playlists + +enum class PlaylistType { + ALBUM, + ARTIST, + TRACK, +} diff --git a/app/src/main/java/me/vanpetegem/accentor/data/tracks/TrackDao.kt b/app/src/main/java/me/vanpetegem/accentor/data/tracks/TrackDao.kt index cd60c820..272ff24f 100644 --- a/app/src/main/java/me/vanpetegem/accentor/data/tracks/TrackDao.kt +++ b/app/src/main/java/me/vanpetegem/accentor/data/tracks/TrackDao.kt @@ -25,6 +25,15 @@ abstract class TrackDao { return tracks.map { t -> Track.fromDb(t, trackArtists.get(t.id, ArrayList()), trackGenres.get(t.id, ArrayList())) } } + open fun getByIds(ids: List): List { + val tracks = getDbTracksByIds(ids) + val tracksByIds = SparseArray() + tracks.forEach { tracksByIds.put(it.id, it) } + val trackGenres = getTrackGenresByTrackIdWhereTrackIds(ids) + val trackArtists = getTrackArtistsByTrackIdWhereTrackIds(ids) + return ids.map { Track.fromDb(tracksByIds.get(it), trackArtists.get(it, ArrayList()), trackGenres.get(it, ArrayList())) } + } + open fun findByIds(ids: List): LiveData> = switchMap(findDbTracksByIds(ids)) { tracks -> switchMap(findTrackArtistsByTrackIdWhereTrackIds(ids)) { trackArtists -> map(findTrackGenresByTrackIdWhereTrackIds(ids)) { trackGenres -> @@ -56,6 +65,14 @@ abstract class TrackDao { } } + open fun getByArtistId(id: Int): List { + val tracks = getDbTracksByArtistId(id) + val ids = tracks.map { it.id } + val trackArtists = getTrackArtistsByTrackIdWhereTrackIds(ids) + val trackGenres = getTrackGenresByTrackIdWhereTrackIds(ids) + return tracks.map { Track.fromDb(it, trackArtists.get(it.id, ArrayList()), trackGenres.get(it.id, ArrayList())) } + } + open fun findByAlbum(album: Album): LiveData> = switchMap(findDbTracksByAlbumId(album.id)) { tracks -> val ids = tracks.map { it.id } switchMap(findTrackArtistsByTrackIdWhereTrackIds(ids)) { trackArtists -> @@ -91,12 +108,18 @@ abstract class TrackDao { @Query("SELECT * FROM tracks WHERE id IN (:ids)") protected abstract fun findDbTracksByIds(ids: List): LiveData> + @Query("SELECT * FROM tracks WHERE id IN (:ids)") + protected abstract fun getDbTracksByIds(ids: List): List + @Query("SELECT * FROM tracks WHERE album_id = :id") protected abstract fun findDbTracksByAlbumId(id: Int): LiveData> @Query("SELECT * FROM tracks WHERE id IN (SELECT track_id FROM track_artists WHERE artist_id = :id)") protected abstract fun findDbTracksByArtistId(id: Int): LiveData> + @Query("SELECT * FROM tracks WHERE id IN (SELECT track_id FROM track_artists WHERE artist_id = :id)") + protected abstract fun getDbTracksByArtistId(id: Int): List + @Query("SELECT * FROM track_artists WHERE track_id = :id") protected abstract fun getDbTrackArtistsById(id: Int): List diff --git a/app/src/main/java/me/vanpetegem/accentor/data/tracks/TrackRepository.kt b/app/src/main/java/me/vanpetegem/accentor/data/tracks/TrackRepository.kt index a94ded56..5a4052d7 100644 --- a/app/src/main/java/me/vanpetegem/accentor/data/tracks/TrackRepository.kt +++ b/app/src/main/java/me/vanpetegem/accentor/data/tracks/TrackRepository.kt @@ -18,7 +18,9 @@ class TrackRepository @Inject constructor( fun findById(id: Int): LiveData = trackDao.findById(id) fun getById(id: Int): Track? = trackDao.getTrackById(id) fun findByIds(ids: List): LiveData> = trackDao.findByIds(ids) + fun getByIds(ids: List): List = trackDao.getByIds(ids) fun findByArtist(artist: Artist): LiveData> = trackDao.findByArtist(artist) + fun getByArtistId(id: Int): List = trackDao.getByArtistId(id) fun findByAlbum(album: Album): LiveData> = trackDao.findByAlbum(album) fun getByAlbum(album: Album): List = trackDao.getByAlbum(album) diff --git a/app/src/main/java/me/vanpetegem/accentor/media/MediaSessionConnection.kt b/app/src/main/java/me/vanpetegem/accentor/media/MediaSessionConnection.kt index af77b11a..1bbf68e8 100644 --- a/app/src/main/java/me/vanpetegem/accentor/media/MediaSessionConnection.kt +++ b/app/src/main/java/me/vanpetegem/accentor/media/MediaSessionConnection.kt @@ -23,6 +23,7 @@ import me.vanpetegem.accentor.data.albums.Album import me.vanpetegem.accentor.data.albums.AlbumRepository import me.vanpetegem.accentor.data.authentication.AuthenticationDataSource import me.vanpetegem.accentor.data.codecconversions.CodecConversionRepository +import me.vanpetegem.accentor.data.playlists.Playlist import me.vanpetegem.accentor.data.preferences.PreferencesDataSource import me.vanpetegem.accentor.data.tracks.Track import me.vanpetegem.accentor.data.tracks.TrackRepository @@ -163,6 +164,11 @@ class MediaSessionConnection @Inject constructor( play(tracks) } + suspend fun play(playlist: Playlist) { + val tracks = playlist.toTrackAlbumPairs(trackRepository, albumRepository) + play(tracks) + } + suspend fun play(track: Track) { val album = albumRepository.getById(track.albumId) album?.let { play(listOf(Pair(track, it))) } @@ -170,6 +176,7 @@ class MediaSessionConnection @Inject constructor( suspend fun addTrackToQueue(track: Track): Unit = addTrackToQueue(track, _queue.value?.size ?: 0) suspend fun addTracksToQueue(album: Album): Unit = addTracksToQueue(album, _queue.value?.size ?: 0) + suspend fun addTracksToQueue(playlist: Playlist): Unit = addTracksToQueue(playlist, _queue.value?.size ?: 0) suspend fun addTrackToQueue(track: Track, index: Int) { val album = albumRepository.getById(track.albumId) @@ -181,6 +188,11 @@ class MediaSessionConnection @Inject constructor( addTracksToQueue(tracks, index) } + suspend fun addTracksToQueue(playlist: Playlist, index: Int) { + val tracks = playlist.toTrackAlbumPairs(trackRepository, albumRepository) + addTracksToQueue(tracks, index) + } + suspend fun clearQueue() = mainScope.launch(Main) { mediaController.clearMediaItems() } suspend fun addTracksToQueue(tracks: List>, index: Int) { diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt index fa9a016c..0a0b5a76 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainActivity.kt @@ -85,6 +85,9 @@ import me.vanpetegem.accentor.ui.home.Home import me.vanpetegem.accentor.ui.login.LoginActivity import me.vanpetegem.accentor.ui.player.PlayerOverlay import me.vanpetegem.accentor.ui.player.PlayerViewModel +import me.vanpetegem.accentor.ui.playlists.PlaylistList +import me.vanpetegem.accentor.ui.playlists.PlaylistToolbar +import me.vanpetegem.accentor.ui.playlists.PlaylistView import me.vanpetegem.accentor.ui.preferences.PreferencesActivity @AndroidEntryPoint @@ -162,6 +165,16 @@ fun Content(mainViewModel: MainViewModel = viewModel(), playerViewModel: PlayerV AlbumView(entry.arguments!!.getInt("albumId"), navController, playerViewModel) } } + composable("playlists") { + Base( + navController, mainViewModel, playerViewModel, toolbar = { PlaylistToolbar(it, mainViewModel) } + ) { PlaylistList(navController, playerViewModel) } + } + composable("playlists/{playlistId}", arguments = listOf(navArgument("playlistId") { type = NavType.IntType })) { entry -> + Base(navController, mainViewModel, playerViewModel) { + PlaylistView(entry.arguments!!.getInt("playlistId"), navController, playerViewModel) + } + } } } } @@ -200,6 +213,10 @@ fun Base( navController.navigate("albums") scope.launch { drawerState.close() } } + DrawerRow(stringResource(R.string.playlists), currentNavigation?.destination?.route == "playlists", R.drawable.ic_menu_playlists) { + navController.navigate("playlists") + scope.launch { drawerState.close() } + } Divider() DrawerRow(stringResource(R.string.preferences), false, R.drawable.ic_menu_preferences) { context.startActivity(Intent(context, PreferencesActivity::class.java)) diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainViewModel.kt index 196c128f..e4e5c6ea 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/main/MainViewModel.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/main/MainViewModel.kt @@ -17,6 +17,7 @@ import me.vanpetegem.accentor.data.albums.AlbumRepository import me.vanpetegem.accentor.data.artists.ArtistRepository import me.vanpetegem.accentor.data.authentication.AuthenticationRepository import me.vanpetegem.accentor.data.codecconversions.CodecConversionRepository +import me.vanpetegem.accentor.data.playlists.PlaylistRepository import me.vanpetegem.accentor.data.plays.PlayRepository import me.vanpetegem.accentor.data.preferences.PreferencesDataSource import me.vanpetegem.accentor.data.tracks.TrackRepository @@ -34,6 +35,7 @@ class MainViewModel @Inject constructor( private val artistRepository: ArtistRepository, private val trackRepository: TrackRepository, private val codecConversionRepository: CodecConversionRepository, + private val playlistRepository: PlaylistRepository, private val playRepository: PlayRepository, private val preferencesDataSource: PreferencesDataSource, ) : AndroidViewModel(application) { @@ -81,6 +83,11 @@ class MainViewModel @Inject constructor( viewModelScope.launch(IO) { playRepository.refresh { decrementRefresh(it) } } + + refreshing.value?.let { refreshing.value = it + 1 } + viewModelScope.launch(IO) { + playlistRepository.refresh { decrementRefresh(it) } + } } suspend fun decrementRefresh(result: Result) { @@ -103,6 +110,7 @@ class MainViewModel @Inject constructor( viewModelScope.launch(IO) { artistRepository.clear() } viewModelScope.launch(IO) { trackRepository.clear() } viewModelScope.launch(IO) { playRepository.clear() } + viewModelScope.launch(IO) { playlistRepository.clear() } viewModelScope.launch(IO) { codecConversionRepository.clear() } viewModelScope.launch(IO) { authenticationRepository.logout() } } diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/player/PlayerViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/player/PlayerViewModel.kt index 4a13b8c4..a9f45f69 100644 --- a/app/src/main/java/me/vanpetegem/accentor/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/me/vanpetegem/accentor/ui/player/PlayerViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.MutableLiveData import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import me.vanpetegem.accentor.data.albums.Album +import me.vanpetegem.accentor.data.playlists.Playlist import me.vanpetegem.accentor.data.tracks.Track import me.vanpetegem.accentor.media.MediaSessionConnection @@ -44,8 +45,10 @@ class PlayerViewModel @Inject constructor( suspend fun stop() = mediaSessionConnection.stop() suspend fun play(album: Album) = mediaSessionConnection.play(album) suspend fun play(track: Track) = mediaSessionConnection.play(track) + suspend fun play(playlist: Playlist) = mediaSessionConnection.play(playlist) suspend fun addTrackToQueue(track: Track) = mediaSessionConnection.addTrackToQueue(track) suspend fun addTracksToQueue(album: Album) = mediaSessionConnection.addTracksToQueue(album) + suspend fun addTracksToQueue(playlist: Playlist) = mediaSessionConnection.addTracksToQueue(playlist) suspend fun addTrackToQueue(track: Track, index: Int) = mediaSessionConnection.addTrackToQueue(track, index) suspend fun addTracksToQueue(album: Album, index: Int) = mediaSessionConnection.addTracksToQueue(album, index) suspend fun clearQueue() = mediaSessionConnection.clearQueue() diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistList.kt b/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistList.kt new file mode 100644 index 00000000..424cf63a --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistList.kt @@ -0,0 +1,99 @@ +package me.vanpetegem.accentor.ui.playlists + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ContentAlpha +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Divider +import androidx.compose.material3.DrawerState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import me.vanpetegem.accentor.R +import me.vanpetegem.accentor.data.playlists.Playlist +import me.vanpetegem.accentor.data.playlists.PlaylistType +import me.vanpetegem.accentor.data.users.User +import me.vanpetegem.accentor.ui.main.BaseToolbar +import me.vanpetegem.accentor.ui.main.MainViewModel +import me.vanpetegem.accentor.ui.main.SearchToolbar +import me.vanpetegem.accentor.ui.player.PlayerViewModel + +@Composable +fun PlaylistList(navController: NavController, playerViewModel: PlayerViewModel, playlistsViewModel: PlaylistsViewModel = hiltViewModel()) { + val playlists by playlistsViewModel.filteredPlaylists.observeAsState() + val users by playlistsViewModel.allUsersById.observeAsState() + val state = rememberLazyListState() + LazyColumn(state = state) { + items(playlists?.size ?: 0, key = { Pair(it, playlists!![it].id) }) { i -> + PlaylistListItem(navController, playerViewModel, i, playlists!![i], users!!.get(playlists!![i].userId)) + } + } +} + +@Composable +fun PlaylistToolbar(drawerState: DrawerState, mainViewModel: MainViewModel, playlistsViewModel: PlaylistsViewModel = hiltViewModel()) { + val searching by playlistsViewModel.searching.observeAsState() + if (searching ?: false) { + val query by playlistsViewModel.query.observeAsState() + SearchToolbar(query ?: "", { playlistsViewModel.setQuery(it) }) { + playlistsViewModel.setSearching(false) + playlistsViewModel.setQuery("") + } + } else { + BaseToolbar( + drawerState, + mainViewModel, + extraActions = { + IconButton(onClick = { playlistsViewModel.setSearching(true) }) { + Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search)) + } + } + ) + } +} + +@Composable +fun PlaylistListItem(navController: NavController, playerViewModel: PlayerViewModel, index: Int, playlist: Playlist, user: User?) { + if (index != 0) { + Divider() + } + val itemInfo = pluralStringResource( + when (playlist.playlistType) { + PlaylistType.ALBUM -> R.plurals.playlist_albums + PlaylistType.ARTIST -> R.plurals.playlist_artists + PlaylistType.TRACK -> R.plurals.playlist_tracks + }, + playlist.itemIds.size, playlist.itemIds.size + ) + Row( + modifier = Modifier.padding(8.dp).clickable { navController.navigate("playlists/${playlist.id}") } + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + playlist.name, + style = MaterialTheme.typography.titleMedium, + ) + Text( + (user?.name ?: "") + " · " + itemInfo, + style = MaterialTheme.typography.titleSmall, + color = LocalContentColor.current.copy(alpha = ContentAlpha.medium), + ) + } + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistView.kt b/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistView.kt new file mode 100644 index 00000000..55dad1fc --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistView.kt @@ -0,0 +1,176 @@ +package me.vanpetegem.accentor.ui.playlists + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material.ContentAlpha +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import me.vanpetegem.accentor.R +import me.vanpetegem.accentor.data.playlists.Playlist +import me.vanpetegem.accentor.data.playlists.PlaylistType +import me.vanpetegem.accentor.ui.albums.AlbumCard +import me.vanpetegem.accentor.ui.artists.ArtistCard +import me.vanpetegem.accentor.ui.player.PlayerViewModel +import me.vanpetegem.accentor.ui.tracks.TrackRow + +@Composable +fun PlaylistView( + id: Int, + navController: NavController, + playerViewModel: PlayerViewModel, + playlistViewModel: PlaylistViewModel = hiltViewModel(), +) { + val scope = rememberCoroutineScope() + val users by playlistViewModel.allUsersById.observeAsState() + val playlistState by playlistViewModel.getPlaylist(id).observeAsState() + if (playlistState != null) { + val playlist = playlistState!! + val user = users!!.get(playlist.userId) + val itemInfo = pluralStringResource( + when (playlist.playlistType) { + PlaylistType.ALBUM -> R.plurals.playlist_albums + PlaylistType.ARTIST -> R.plurals.playlist_artists + PlaylistType.TRACK -> R.plurals.playlist_tracks + }, + playlist.itemIds.size, playlist.itemIds.size + ) + Column() { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Text( + playlist.name, + style = MaterialTheme.typography.headlineLarge, + ) + Text( + (user?.name ?: "") + " · " + itemInfo, + style = MaterialTheme.typography.headlineMedium, + color = LocalContentColor.current.copy(alpha = ContentAlpha.medium), + ) + Row(modifier = Modifier.padding(8.dp)) { + IconButton(onClick = { scope.launch(IO) { playerViewModel.play(playlist) } }) { + Icon(painterResource(R.drawable.ic_play), contentDescription = stringResource(R.string.play_now)) + } + IconButton(onClick = { scope.launch(IO) { playerViewModel.addTracksToQueue(playlist) } }) { + Icon(painterResource(R.drawable.ic_queue_add), contentDescription = stringResource(R.string.play_last)) + } + } + } + when (playlist.playlistType) { + PlaylistType.ALBUM -> PlaylistAlbumContent(navController, playerViewModel, playlistViewModel, playlist) + PlaylistType.ARTIST -> PlaylistArtistContent(navController, playlistViewModel, playlist) + PlaylistType.TRACK -> PlaylistTrackContent(navController, playerViewModel, playlistViewModel, playlist) + } + } + } +} + +@Composable +fun PlaylistAlbumContent( + navController: NavController, + playerViewModel: PlayerViewModel, + playlistViewModel: PlaylistViewModel, + playlist: Playlist, +) { + val albums by playlistViewModel.allAlbumsById.observeAsState() + var boxSize by remember { mutableStateOf(IntSize.Zero) } + val cardsPerRow: Int = with(LocalDensity.current) { boxSize.width / 192.dp.toPx().toInt() } + val gridState = rememberLazyGridState() + if (albums != null) { + LazyVerticalGrid( + columns = if (cardsPerRow >= 2) GridCells.Adaptive(minSize = 192.dp) else GridCells.Fixed(2), + state = gridState, + modifier = Modifier.onGloballyPositioned { boxSize = it.size }, + ) { + items(playlist.itemIds.size) { i -> + val album = albums!![playlist.itemIds[i]] + if (album != null) { + AlbumCard(albums!![playlist.itemIds[i]], navController, playerViewModel) + } else { + Box(Modifier.height(192.dp)) {} + } + } + } + } +} + +@Composable +fun PlaylistArtistContent( + navController: NavController, + playlistViewModel: PlaylistViewModel, + playlist: Playlist, +) { + val artists by playlistViewModel.allArtistsById.observeAsState() + var boxSize by remember { mutableStateOf(IntSize.Zero) } + val cardsPerRow: Int = with(LocalDensity.current) { boxSize.width / 192.dp.toPx().toInt() } + val gridState = rememberLazyGridState() + if (artists != null) { + LazyVerticalGrid( + columns = if (cardsPerRow >= 2) GridCells.Adaptive(minSize = 192.dp) else GridCells.Fixed(2), + state = gridState, + modifier = Modifier.onGloballyPositioned { boxSize = it.size }, + ) { + items(playlist.itemIds.size) { i -> + val artist = artists!![playlist.itemIds[i]] + if (artist != null) { + ArtistCard(navController, artists!![playlist.itemIds[i]]) + } else { + Box(Modifier.height(192.dp)) {} + } + } + } + } +} + +@Composable +fun PlaylistTrackContent( + navController: NavController, + playerViewModel: PlayerViewModel, + playlistViewModel: PlaylistViewModel, + playlist: Playlist, +) { + val tracks by playlistViewModel.getTracksForPlaylist(playlist).observeAsState() + if (tracks != null) { + LazyColumn() { + items(playlist.itemIds.size) { i -> + val track = tracks!![playlist.itemIds[i]] + if (track != null) { + TrackRow(tracks!![playlist.itemIds[i]], navController, playerViewModel) + } else { + Box(Modifier.height(30.dp)) {} + Divider() + } + } + } + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistViewModel.kt new file mode 100644 index 00000000..b7fc010c --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistViewModel.kt @@ -0,0 +1,41 @@ +package me.vanpetegem.accentor.ui.playlists + +import android.app.Application +import android.util.SparseArray +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations.map +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import me.vanpetegem.accentor.data.albums.Album +import me.vanpetegem.accentor.data.albums.AlbumRepository +import me.vanpetegem.accentor.data.artists.Artist +import me.vanpetegem.accentor.data.artists.ArtistRepository +import me.vanpetegem.accentor.data.playlists.Playlist +import me.vanpetegem.accentor.data.playlists.PlaylistRepository +import me.vanpetegem.accentor.data.tracks.Track +import me.vanpetegem.accentor.data.tracks.TrackRepository +import me.vanpetegem.accentor.data.users.User +import me.vanpetegem.accentor.data.users.UserRepository + +@HiltViewModel +class PlaylistViewModel @Inject constructor( + application: Application, + private val playlistRepository: PlaylistRepository, + private val userRepository: UserRepository, + private val albumRepository: AlbumRepository, + private val artistRepository: ArtistRepository, + private val trackRepository: TrackRepository, +) : AndroidViewModel(application) { + val allUsersById: LiveData> = userRepository.allUsersById + val allAlbumsById: LiveData> = albumRepository.allAlbumsById + val allArtistsById: LiveData> = artistRepository.allArtistsById + + fun getPlaylist(id: Int): LiveData = map(playlistRepository.allPlaylistsById) { playlists -> playlists[id] } + + fun getTracksForPlaylist(playlist: Playlist): LiveData> = map(trackRepository.findByIds(playlist.itemIds)) { + val map = SparseArray() + it.forEach { t -> map.put(t.id, t) } + map + } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistsViewModel.kt b/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistsViewModel.kt new file mode 100644 index 00000000..5bc90a1b --- /dev/null +++ b/app/src/main/java/me/vanpetegem/accentor/ui/playlists/PlaylistsViewModel.kt @@ -0,0 +1,45 @@ +package me.vanpetegem.accentor.ui.playlists + +import android.app.Application +import android.util.SparseArray +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations.map +import androidx.lifecycle.Transformations.switchMap +import dagger.hilt.android.lifecycle.HiltViewModel +import java.text.Normalizer +import javax.inject.Inject +import me.vanpetegem.accentor.data.playlists.Playlist +import me.vanpetegem.accentor.data.playlists.PlaylistRepository +import me.vanpetegem.accentor.data.users.User +import me.vanpetegem.accentor.data.users.UserRepository + +@HiltViewModel +class PlaylistsViewModel @Inject constructor( + application: Application, + private val playlistRepository: PlaylistRepository, + private val userRepository: UserRepository, +) : AndroidViewModel(application) { + val allPlaylists: LiveData> = playlistRepository.allPlaylists + val allUsersById: LiveData> = userRepository.allUsersById + + private val _searching = MutableLiveData(false) + val searching: LiveData = _searching + + private val _query = MutableLiveData("") + val query: LiveData = _query + + val filteredPlaylists: LiveData> = switchMap(allPlaylists) { playlists -> + map(query) { query -> + if (query.equals("")) { + playlists + } else { + playlists.filter { p -> p.name.contains(Normalizer.normalize(query, Normalizer.Form.NFKD), ignoreCase = true) } + } + } + } + + fun setSearching(value: Boolean) { _searching.value = value } + fun setQuery(value: String) { _query.value = value } +} diff --git a/app/src/main/java/me/vanpetegem/accentor/util/FuelGson.kt b/app/src/main/java/me/vanpetegem/accentor/util/FuelGson.kt index 6bf13223..eec38785 100644 --- a/app/src/main/java/me/vanpetegem/accentor/util/FuelGson.kt +++ b/app/src/main/java/me/vanpetegem/accentor/util/FuelGson.kt @@ -16,6 +16,8 @@ import java.io.Reader import java.time.Instant import java.time.LocalDate import java.time.format.DateTimeFormatter +import me.vanpetegem.accentor.data.playlists.Access +import me.vanpetegem.accentor.data.playlists.PlaylistType import me.vanpetegem.accentor.data.tracks.Role import me.vanpetegem.accentor.data.users.Permission @@ -47,6 +49,32 @@ fun gsonObject(): Gson { } ) + builder.registerTypeAdapter( + Access::class.java, + object : TypeAdapter() { + override fun write(out: JsonWriter, value: Access) { + out.value(value.name.lowercase()) + } + + override fun read(`in`: JsonReader): Access { + return Access.valueOf(`in`.nextString().uppercase()) + } + } + ) + + builder.registerTypeAdapter( + PlaylistType::class.java, + object : TypeAdapter() { + override fun write(out: JsonWriter, value: PlaylistType) { + out.value(value.name.lowercase()) + } + + override fun read(`in`: JsonReader): PlaylistType { + return PlaylistType.valueOf(`in`.nextString().uppercase()) + } + } + ) + builder.registerTypeAdapter( Role::class.java, object : TypeAdapter() { diff --git a/app/src/main/java/me/vanpetegem/accentor/util/RoomTypeConverters.kt b/app/src/main/java/me/vanpetegem/accentor/util/RoomTypeConverters.kt index 60b15d19..8460e83e 100644 --- a/app/src/main/java/me/vanpetegem/accentor/util/RoomTypeConverters.kt +++ b/app/src/main/java/me/vanpetegem/accentor/util/RoomTypeConverters.kt @@ -4,6 +4,8 @@ import androidx.room.TypeConverter import java.time.Instant import java.time.LocalDate import java.time.format.DateTimeFormatter +import me.vanpetegem.accentor.data.playlists.Access +import me.vanpetegem.accentor.data.playlists.PlaylistType import me.vanpetegem.accentor.data.tracks.Role import me.vanpetegem.accentor.data.users.Permission @@ -58,6 +60,48 @@ class RoomTypeConverters { } } + @TypeConverter + fun playlistTypeFromInt(value: Int?): PlaylistType? { + return when (value) { + null -> null + 1 -> PlaylistType.ALBUM + 2 -> PlaylistType.ARTIST + 3 -> PlaylistType.TRACK + else -> null + } + } + + @TypeConverter + fun playlistTypeToInt(value: PlaylistType?): Int? { + return when (value) { + null -> null + PlaylistType.ALBUM -> 1 + PlaylistType.ARTIST -> 2 + PlaylistType.TRACK -> 3 + } + } + + @TypeConverter + fun accessFromInt(value: Int?): Access? { + return when (value) { + null -> null + 1 -> Access.SHARED + 2 -> Access.PERSONAL + 3 -> Access.SECRET + else -> null + } + } + + @TypeConverter + fun accessToInt(value: Access?): Int? { + return when (value) { + null -> null + Access.SHARED -> 1 + Access.PERSONAL -> 2 + Access.SECRET -> 3 + } + } + @TypeConverter fun localDateFromString(value: String?): LocalDate? { value ?: return null diff --git a/app/src/main/res/drawable/ic_menu_playlists.xml b/app/src/main/res/drawable/ic_menu_playlists.xml new file mode 100644 index 00000000..84407442 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_playlists.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e627151..ee8a5dd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,6 +64,7 @@ Random artists Artists Albums + Playlists Tracks Go to album Go to %s @@ -72,4 +73,17 @@ No albums could be found No albums were released on this day No artists could be found + + + %d album + %d albums + + + %d artist + %d artists + + + %d track + %d tracks +