diff --git a/app/build.gradle b/app/build.gradle index e16fa74b..1671bec5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,6 +98,7 @@ dependencies { implementation "io.coil-kt:coil-compose:2.0.0-rc03" implementation 'com.google.accompanist:accompanist-systemuicontroller:0.24.6-alpha' + implementation 'com.google.accompanist:accompanist-pager:0.24.6-alpha' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1' implementation 'androidx.activity:activity-compose:1.4.0' @@ -123,6 +124,11 @@ dependencies { implementation "com.squareup.retrofit2:retrofit:2.9.0" implementation "com.squareup.retrofit2:converter-moshi:2.9.0" implementation "com.squareup.retrofit2:converter-protobuf:2.9.0" + + implementation("androidx.room:room-runtime:$roomVersion") + implementation("androidx.room:room-ktx:$roomVersion") + implementation("androidx.room:room-paging:2.5.0-alpha01") + kapt "androidx.room:room-compiler:$roomVersion" } protobuf { diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/DeviceIdProvider.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/DeviceIdProvider.kt index e4eb926d..fc67be91 100644 --- a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/DeviceIdProvider.kt +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/DeviceIdProvider.kt @@ -15,4 +15,9 @@ object DeviceIdProvider { Build.MODEL } } + + fun getRandomString(length: Int) : String { + val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + return (1..length).map { allowedChars.random() }.joinToString("") + } } \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/api/SpCollectionApi.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/api/SpCollectionApi.kt index a4c05faf..c06b1a40 100644 --- a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/api/SpCollectionApi.kt +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/api/SpCollectionApi.kt @@ -2,15 +2,20 @@ package bruhcollective.itaysonlab.jetispot.core.api import com.spotify.collection2.v2.proto.Collection2V2 import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.Headers import retrofit2.http.POST interface SpCollectionApi { @POST("write") + @Headers("Accept: application/vnd.collection-v2.spotify.proto", "Content-Type: application/vnd.collection-v2.spotify.proto") suspend fun write(@Body data: Collection2V2.WriteRequest) @POST("delta") + @Headers("Accept: application/vnd.collection-v2.spotify.proto", "Content-Type: application/vnd.collection-v2.spotify.proto") suspend fun delta(@Body data: Collection2V2.DeltaRequest): Collection2V2.DeltaResponse @POST("paging") + @Headers("Accept: application/vnd.collection-v2.spotify.proto", "Content-Type: application/vnd.collection-v2.spotify.proto") suspend fun paging(@Body data: Collection2V2.PageRequest): Collection2V2.PageResponse } \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/SpCollectionManager.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/SpCollectionManager.kt index 65903aaf..57136ebe 100644 --- a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/SpCollectionManager.kt +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/SpCollectionManager.kt @@ -1,39 +1,276 @@ package bruhcollective.itaysonlab.jetispot.core.collection +import android.util.Log +import bruhcollective.itaysonlab.jetispot.core.DeviceIdProvider import bruhcollective.itaysonlab.jetispot.core.SpSessionManager import bruhcollective.itaysonlab.jetispot.core.api.SpCollectionApi +import bruhcollective.itaysonlab.jetispot.core.collection.db.LocalCollectionRepository +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionAlbum +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtist +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtistMetadata +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionTrack +import com.google.protobuf.ByteString import com.spotify.collection2.v2.proto.Collection2V2 +import com.spotify.extendedmetadata.ExtendedMetadata +import com.spotify.extendedmetadata.ExtensionKindOuterClass +import com.spotify.metadata.Metadata import kotlinx.coroutines.* +import xyz.gianlu.librespot.common.Utils +import xyz.gianlu.librespot.metadata.* +import java.util.concurrent.Executors import javax.inject.Inject class SpCollectionManager @Inject constructor( private val spSessionManager: SpSessionManager, - private val collectionApi: SpCollectionApi -): CoroutineScope by CoroutineScope(Dispatchers.IO + SupervisorJob()) { + private val collectionApi: SpCollectionApi, + private val dbRepository: LocalCollectionRepository +) { + // it's important to use queuing here + private val scopeDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private val scope = CoroutineScope(scopeDispatcher + SupervisorJob()) + private val pendingWriteOperations = mutableListOf() - sealed class CollectionWriteOp { - class Add(val spId: String): CollectionWriteOp() { val additionDate = System.currentTimeMillis() / 1000L } - class Remove(val spId: String): CollectionWriteOp() + sealed class CollectionWriteOp(val toSet: String) { + class Add(val spId: String, toSet: String) : CollectionWriteOp(toSet) { + val additionDate = (System.currentTimeMillis() / 1000L).toInt() + } + + class Remove(val spId: String, toSet: String) : CollectionWriteOp(toSet) + } + + private suspend fun processPendingWrites() { + if (pendingWriteOperations.isEmpty()) return + + collectionApi.write(Collection2V2.WriteRequest.newBuilder().apply { + username = spSessionManager.session.username() + set = "collection" + clientUpdateId = DeviceIdProvider.getRandomString(16) + addAllItems(pendingWriteOperations.map { + when (it) { + is CollectionWriteOp.Add -> Collection2V2.CollectionItem.newBuilder().setUri(it.spId) + .setAddedAt(it.additionDate).build() + is CollectionWriteOp.Remove -> Collection2V2.CollectionItem.newBuilder().setUri(it.spId) + .setIsRemoved(true).build() + } + }) + }.build()) + } + + // scans network collection for tracks, albums, pins and artists + suspend fun scan() = withContext(scopeDispatcher) { + performScan("collection") + performScan("artist") + // performScan("ylpin") + } + + suspend fun artists() = withContext(scopeDispatcher) { + performScanIfEmpty("artist") + dbRepository.getArtists() + } + + suspend fun albums() = withContext(scopeDispatcher) { + performScanIfEmpty("collection") + dbRepository.getAlbums() + } + + private suspend fun performScanIfEmpty(of: String) { + dbRepository.getCollection(of) ?: performScan(of) + } + + /** + * scan&add all info needed + * + * for "collection" + * - fetch data (ID's: albums and tracks) + * - fetch metadata (albums&tracks - title/artist/picture, also fetch artists to get genre information) + * - save to DB + * + * for "artist" + "ylpin" + * - fetch data, metadata and save to DB + */ + private suspend fun performScan(of: String) { + Log.d("SpColManager", "Performing scan of $of") + + val data = performPagingScan(of) + + Log.d("SpColManager", "Scan of $of completed [total = ${data.first.size}]") } - init { - launch { - while (true) { - processPendingWrites() - delay(5000L) + private fun spotifyIdToKind(id: String) = when (id.split(":")[1]) { + "track" -> ExtensionKindOuterClass.ExtensionKind.TRACK_V4 + "album" -> ExtensionKindOuterClass.ExtensionKind.ALBUM_V4 + "artist" -> ExtensionKindOuterClass.ExtensionKind.ARTIST_V4 + else -> ExtensionKindOuterClass.ExtensionKind.UNKNOWN_EXTENSION + } + + private fun bytesToPicUrl(bytes: ByteString) = ImageId.fromHex(Utils.bytesToHex(bytes)).hexId() + + private suspend fun performPagingScan(of: String): Pair, String> { + val items = mutableListOf() + + val syncToken: String + var pToken = "" + + while (true) { + Log.d("SpColManager", "Performing page request of $of [pToken = $pToken]") + + val page = collectionApi.paging(Collection2V2.PageRequest.newBuilder().apply { + username = spSessionManager.session.username() + set = of + paginationToken = pToken + limit = 300 + }.build()) + + items.addAll(page.itemsList) + + Log.d( + "SpColManager", + "Performing page metadata request of $of [count = ${page.itemsList.size}]" + ) + + val requests = mutableListOf() + + page.itemsList.forEach { ci -> + val kind = spotifyIdToKind(ci.uri) + + if (kind != ExtensionKindOuterClass.ExtensionKind.UNKNOWN_EXTENSION) { + requests.add( + ExtendedMetadata.EntityRequest.newBuilder().setEntityUri(ci.uri) + .addQuery(ExtendedMetadata.ExtensionQuery.newBuilder().setExtensionKind(kind).build()) + .build() + ) + } + } + + val metadata = UnpackedMetadataResponse( + spSessionManager.session.api().getExtendedMetadata( + ExtendedMetadata.BatchedEntityRequest.newBuilder().addAllEntityRequest(requests).build() + ).extendedMetadataList + ) + + if (metadata.tracks.isNotEmpty()) { + // also request artists to get genre data + + val mappedArtists = UnpackedMetadataResponse(spSessionManager.session.api() + .getExtendedMetadata( + ExtendedMetadata.BatchedEntityRequest.newBuilder().addAllEntityRequest( + metadata.tracks.map { + ExtendedMetadata.EntityRequest.newBuilder().setEntityUri(ArtistId.fromHex(Utils.bytesToHex(it.value.artistList.first().gid)).toSpotifyUri()).addQuery( + ExtendedMetadata.ExtensionQuery.newBuilder() + .setExtensionKind(ExtensionKindOuterClass.ExtensionKind.ARTIST_V4).build() + ).build() + }.distinctBy { it.entityUri } + ).build() + ) + .extendedMetadataList + ).artists.map { + CollectionArtistMetadata( + Utils.bytesToHex(it.value.gid), + it.value.genreList.joinToString("|") + ) + }.toTypedArray() + + dbRepository.insertMetaArtists(*mappedArtists) + } + + val mappedRequest = page.itemsList.associate { Pair(it.uri, it.addedAt) } + + dbRepository.insertTracks(*metadata.tracks.values.map { track -> + CollectionTrack( + id = TrackId.fromHex(Utils.bytesToHex(track.gid)).hexId(), + uri = TrackId.fromHex(Utils.bytesToHex(track.gid)).toSpotifyUri(), + name = track.name, + albumId = Utils.bytesToHex(track.album.gid), + albumName = track.album.name, + mainArtistId = Utils.bytesToHex(track.artistList.first().gid), + rawArtistsData = track.artistList.joinToString("|") { artist -> "${Utils.bytesToHex(artist.gid)}=${artist.name}" }, + hasLyrics = track.hasLyrics, + isExplicit = track.explicit, + duration = track.duration, + picture = bytesToPicUrl(track.album.coverGroup.imageList.first { it.size == Metadata.Image.Size.DEFAULT }.fileId), + addedAt = mappedRequest[TrackId.fromHex(Utils.bytesToHex(track.gid)).toSpotifyUri()]!! + ) + }.toTypedArray()) + + dbRepository.insertAlbums(*metadata.albums.values.map { album -> + CollectionAlbum( + id = AlbumId.fromHex(Utils.bytesToHex(album.gid)).hexId(), + uri = AlbumId.fromHex(Utils.bytesToHex(album.gid)).toSpotifyUri(), + rawArtistsData = album.artistList.joinToString("|") { artist -> "${Utils.bytesToHex(artist.gid)}=${artist.name}" }, + name = album.name, + picture = bytesToPicUrl(album.coverGroup.imageList.first { it.size == Metadata.Image.Size.DEFAULT }.fileId), + addedAt = mappedRequest[AlbumId.fromHex(Utils.bytesToHex(album.gid)).toSpotifyUri()]!! + ) + }.toTypedArray()) + + dbRepository.insertArtists(*metadata.artists.values.map { artist -> + CollectionArtist( + id = ArtistId.fromHex(Utils.bytesToHex(artist.gid)).hexId(), + uri = ArtistId.fromHex(Utils.bytesToHex(artist.gid)).toSpotifyUri(), + name = artist.name, + picture = bytesToPicUrl(artist.portraitGroup.imageList.first { it.size == Metadata.Image.Size.DEFAULT }.fileId), + addedAt = mappedRequest[ArtistId.fromHex(Utils.bytesToHex(artist.gid)).toSpotifyUri()]!! + ) + }.toTypedArray()) + + if (!page.nextPageToken.isNullOrEmpty()) { + // next page + pToken = page.nextPageToken + } else { + // no more pages remain + syncToken = page.syncToken + break } } + + dbRepository.insertOrUpdateCollection(of, syncToken) + return Pair(items, syncToken) } - private suspend fun processPendingWrites() { - if (pendingWriteOperations.isEmpty()) return + class UnpackedMetadataResponse( + dataArray: List + ) { + var tracks: Map = mapOf() + private set - /*collectionApi.write(Collection2V2.WriteRequest.newBuilder().apply { - setUsername("") - setSet("") - addAllItems() - setClientUpdateId(0) - }.build())*/ + var artists: Map = mapOf() + private set + + var albums: Map = mapOf() + private set + + init { + dataArray.forEach { arr -> + when (arr.extensionKind) { + ExtensionKindOuterClass.ExtensionKind.TRACK_V4 -> { + tracks = arr.extensionDataList.associate { + Pair( + it.entityUri, + Metadata.Track.parseFrom(it.extensionData.value) + ) + } + } + + ExtensionKindOuterClass.ExtensionKind.ALBUM_V4 -> { + albums = arr.extensionDataList.associate { + Pair( + it.entityUri, + Metadata.Album.parseFrom(it.extensionData.value) + ) + } + } + + ExtensionKindOuterClass.ExtensionKind.ARTIST_V4 -> { + artists = arr.extensionDataList.associate { + Pair( + it.entityUri, + Metadata.Artist.parseFrom(it.extensionData.value) + ) + } + } + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/LocalCollectionDao.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/LocalCollectionDao.kt new file mode 100644 index 00000000..255d0406 --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/LocalCollectionDao.kt @@ -0,0 +1,47 @@ +package bruhcollective.itaysonlab.jetispot.core.collection.db + +import androidx.room.* +import bruhcollective.itaysonlab.jetispot.core.collection.db.model.LocalCollectionCategory +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionAlbum +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtist +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtistMetadata +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionTrack + +@Dao +interface LocalCollectionDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun updateCollectionCategory(item: LocalCollectionCategory) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addTracks(vararg items: CollectionTrack) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addArtists(vararg items: CollectionArtist) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addMetaArtists(vararg items: CollectionArtistMetadata) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addAlbums(vararg items: CollectionAlbum) + + @Query("DELETE FROM lcTracks WHERE id IN (:ids)") + suspend fun deleteTracks(vararg ids: String) + + @Query("DELETE FROM lcArtists WHERE id IN (:ids)") + suspend fun deleteArtists(vararg ids: String) + + @Query("DELETE FROM lcMetaArtists WHERE id IN (:ids)") + suspend fun deleteMetaArtists(vararg ids: String) + + @Query("DELETE FROM lcAlbums WHERE id IN (:ids)") + suspend fun deleteAlbums(vararg ids: String) + + @Query("SELECT * from lcArtists ORDER BY addedAt DESC") + suspend fun getArtists(): List + + @Query("SELECT * from lcAlbums ORDER BY addedAt DESC") + suspend fun getAlbums(): List + + @Query("SELECT * from lcTypes WHERE type = :of") + suspend fun getCollection(of: String): LocalCollectionCategory? +} \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/LocalCollectionDatabase.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/LocalCollectionDatabase.kt new file mode 100644 index 00000000..c143e54b --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/LocalCollectionDatabase.kt @@ -0,0 +1,22 @@ +package bruhcollective.itaysonlab.jetispot.core.collection.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import bruhcollective.itaysonlab.jetispot.core.collection.db.model.LocalCollectionCategory +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionAlbum +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtist +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtistMetadata +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionTrack + +@Database( + entities = [ + LocalCollectionCategory::class, + CollectionArtist::class, + CollectionAlbum::class, + CollectionTrack::class, + CollectionArtistMetadata::class, + ], version = 1 +) +abstract class LocalCollectionDatabase : RoomDatabase() { + abstract fun dao(): LocalCollectionDao +} \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/LocalCollectionRepository.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/LocalCollectionRepository.kt new file mode 100644 index 00000000..c2972c01 --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/LocalCollectionRepository.kt @@ -0,0 +1,30 @@ +package bruhcollective.itaysonlab.jetispot.core.collection.db + +import bruhcollective.itaysonlab.jetispot.core.collection.db.model.LocalCollectionCategory +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionAlbum +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtist +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtistMetadata +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionTrack +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocalCollectionRepository @Inject constructor( + private val dao: LocalCollectionDao +) { + suspend fun insertOrUpdateCollection( + collectionSet: String, + syncToken: String + ) { + dao.updateCollectionCategory(LocalCollectionCategory(collectionSet, syncToken)) + } + + suspend fun insertMetaArtists(vararg items: CollectionArtistMetadata) = dao.addMetaArtists(*items) + suspend fun insertArtists(vararg items: CollectionArtist) = dao.addArtists(*items) + suspend fun insertAlbums(vararg items: CollectionAlbum) = dao.addAlbums(*items) + suspend fun insertTracks(vararg items: CollectionTrack) = dao.addTracks(*items) + + suspend fun getArtists() = dao.getArtists() + suspend fun getAlbums() = dao.getAlbums() + suspend fun getCollection(of: String): LocalCollectionCategory? = dao.getCollection(of) +} \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model/LocalCollectionCategory.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model/LocalCollectionCategory.kt new file mode 100644 index 00000000..eaf7f24b --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model/LocalCollectionCategory.kt @@ -0,0 +1,10 @@ +package bruhcollective.itaysonlab.jetispot.core.collection.db.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "lcTypes") +data class LocalCollectionCategory( + @PrimaryKey val type: String, + val syncToken: String +) \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionAlbum.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionAlbum.kt new file mode 100644 index 00000000..1eee57e2 --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionAlbum.kt @@ -0,0 +1,16 @@ +package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.Relation +import xyz.gianlu.librespot.metadata.SpotifyId + +@Entity(tableName = "lcAlbums") +data class CollectionAlbum( + @PrimaryKey val id: String, + val uri: String, + val name: String, + val rawArtistsData: String, + val picture: String, + val addedAt: Int +): CollectionModel \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionArtist.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionArtist.kt new file mode 100644 index 00000000..690076fe --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionArtist.kt @@ -0,0 +1,15 @@ +package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.Relation +import xyz.gianlu.librespot.metadata.SpotifyId + +@Entity(tableName = "lcArtists") +data class CollectionArtist( + @PrimaryKey val id: String, + val uri: String, + val name: String, + val picture: String, + val addedAt: Int +): CollectionModel \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionArtistMetadata.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionArtistMetadata.kt new file mode 100644 index 00000000..db1dac6d --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionArtistMetadata.kt @@ -0,0 +1,12 @@ +package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.Relation +import xyz.gianlu.librespot.metadata.SpotifyId + +@Entity(tableName = "lcMetaArtists") +data class CollectionArtistMetadata( + @PrimaryKey val id: String, + val genres: String, // genre 1|genre 2 +) \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionModel.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionModel.kt new file mode 100644 index 00000000..8eb49383 --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionModel.kt @@ -0,0 +1,4 @@ +package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 + +interface CollectionModel { +} \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionTrack.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionTrack.kt new file mode 100644 index 00000000..cb41fdc3 --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/collection/db/model2/CollectionTrack.kt @@ -0,0 +1,20 @@ +package bruhcollective.itaysonlab.jetispot.core.collection.db.model2 + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "lcTracks") +data class CollectionTrack( + @PrimaryKey val id: String, + val uri: String, + val name: String, + val albumId: String, + val albumName: String, + val mainArtistId: String, // for metadata&joins + val rawArtistsData: String, // for UI, format: ID=Name (example: 1=Artist|2=Artist2) + val hasLyrics: Boolean, + val isExplicit: Boolean, + val duration: Int, + val picture: String, + val addedAt: Int +) \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/di/ApiModule.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/di/ApiModule.kt index 23b8e9d6..82d23109 100644 --- a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/di/ApiModule.kt +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/di/ApiModule.kt @@ -3,6 +3,7 @@ package bruhcollective.itaysonlab.jetispot.core.di import bruhcollective.itaysonlab.jetispot.core.DeviceIdProvider import bruhcollective.itaysonlab.jetispot.core.SpSessionManager import bruhcollective.itaysonlab.jetispot.core.api.ClientTokenHandler +import bruhcollective.itaysonlab.jetispot.core.api.SpCollectionApi import bruhcollective.itaysonlab.jetispot.core.api.SpInternalApi import bruhcollective.itaysonlab.jetispot.core.di.ext.interceptRequest import com.squareup.moshi.Moshi @@ -67,4 +68,8 @@ object ApiModule { @Provides @Singleton fun provideInternalApi(retrofit: Retrofit): SpInternalApi = retrofit.newBuilder().baseUrl("https://spclient.wg.spotify.com").build().create(SpInternalApi::class.java) + + @Provides + @Singleton + fun provideCollectionApi(retrofit: Retrofit): SpCollectionApi = retrofit.newBuilder().baseUrl("https://spclient.wg.spotify.com/collection/v2/").build().create(SpCollectionApi::class.java) } \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/di/CollectionModule.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/di/CollectionModule.kt new file mode 100644 index 00000000..18d78181 --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/core/di/CollectionModule.kt @@ -0,0 +1,39 @@ +package bruhcollective.itaysonlab.jetispot.core.di + +import android.content.Context +import androidx.room.Room +import bruhcollective.itaysonlab.jetispot.core.SpSessionManager +import bruhcollective.itaysonlab.jetispot.core.api.SpCollectionApi +import bruhcollective.itaysonlab.jetispot.core.collection.SpCollectionManager +import bruhcollective.itaysonlab.jetispot.core.collection.db.LocalCollectionDao +import bruhcollective.itaysonlab.jetispot.core.collection.db.LocalCollectionDatabase +import bruhcollective.itaysonlab.jetispot.core.collection.db.LocalCollectionRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object CollectionModule { + @Provides + fun provideDatabase ( + @ApplicationContext appCtx: Context + ): LocalCollectionDatabase = Room.databaseBuilder(appCtx, LocalCollectionDatabase::class.java, "spCollection").build() + + @Provides + fun provideDao ( + db: LocalCollectionDatabase + ): LocalCollectionDao = db.dao() + + fun provideRepository ( + dao: LocalCollectionDao + ): LocalCollectionRepository = LocalCollectionRepository(dao) + + fun provideManager ( + spSessionManager: SpSessionManager, + collectionApi: SpCollectionApi, + repository: LocalCollectionRepository + ): SpCollectionManager = SpCollectionManager(spSessionManager, collectionApi, repository) +} \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/Screen.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/Screen.kt index feff38b0..d5766d64 100644 --- a/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/Screen.kt +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/Screen.kt @@ -25,6 +25,8 @@ import bruhcollective.itaysonlab.jetispot.ui.screens.config.NormalizationConfigS import bruhcollective.itaysonlab.jetispot.ui.screens.config.QualityConfigScreen import bruhcollective.itaysonlab.jetispot.ui.screens.dac.DacRendererScreen import bruhcollective.itaysonlab.jetispot.ui.screens.hub.HubScreen +import bruhcollective.itaysonlab.jetispot.ui.screens.yourlibrary.YourLibraryContainerScreen +import bruhcollective.itaysonlab.jetispot.ui.screens.yourlibrary.debug.YourLibraryDebugScreen sealed class Screen(open val route: String, val screenProvider: @Composable (navController: NavController) -> Unit) { // == CORE == @@ -65,7 +67,7 @@ sealed class Screen(open val route: String, val screenProvider: @Composable (nav name = R.string.tab_library, iconProvider = { Icons.Default.LibraryMusic }, screenProvider = { navController -> - //ConfigScreen(navController) + YourLibraryContainerScreen(navController) } ) // @@ -91,6 +93,10 @@ sealed class Screen(open val route: String, val screenProvider: @Composable (nav object DacViewAllPlans: Screen("dac/viewAllPlans", { navController -> DacRendererScreen(navController, stringResource(id = R.string.all_plans), loader = { getAllPlans() }) }) + + object YLDebug: Screen("library/debug", { navController -> + YourLibraryDebugScreen(navController) + }) } // Abstracts @@ -100,5 +106,5 @@ abstract class BottomNavigationScreen(@StringRes val name: Int, route: String, v interface FullscreenModeScreen // Extensions -val allScreens = listOf(Screen.CoreLoadingScreen, Screen.Authorization, Screen.Feed, Screen.Search, Screen.Library, Screen.Config, Screen.QualityConfig, Screen.NormalizationConfig, Screen.DacViewAllPlans, Screen.DacViewCurrentPlan).associateBy { it.route } +val allScreens = listOf(Screen.CoreLoadingScreen, Screen.Authorization, Screen.Feed, Screen.Search, Screen.Library, Screen.Config, Screen.QualityConfig, Screen.NormalizationConfig, Screen.DacViewAllPlans, Screen.DacViewCurrentPlan, Screen.YLDebug).associateBy { it.route } val bottomNavigationScreens: List = allScreens.values.filterIsInstance() \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/yourlibrary/YourLibraryContainerScreen.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/yourlibrary/YourLibraryContainerScreen.kt new file mode 100644 index 00000000..35ca0135 --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/yourlibrary/YourLibraryContainerScreen.kt @@ -0,0 +1,203 @@ +package bruhcollective.itaysonlab.jetispot.ui.screens.yourlibrary + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material3.* +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import bruhcollective.itaysonlab.jetispot.core.SpSessionManager +import bruhcollective.itaysonlab.jetispot.core.collection.SpCollectionManager +import bruhcollective.itaysonlab.jetispot.core.collection.db.LocalCollectionDao +import bruhcollective.itaysonlab.jetispot.core.collection.db.LocalCollectionRepository +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionAlbum +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionArtist +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionModel +import bruhcollective.itaysonlab.jetispot.ui.screens.dac.DacViewModel +import bruhcollective.itaysonlab.jetispot.ui.shared.PagingErrorPage +import bruhcollective.itaysonlab.jetispot.ui.shared.PagingLoadingPage +import coil.compose.AsyncImage +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.rememberPagerState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class) +@Composable +fun YourLibraryContainerScreen( + navController: NavController, + viewModel: YourLibraryContainerViewModel = viewModel() +) { + val scope = rememberCoroutineScope() + + val pagerState = rememberPagerState() + val currentTabIndex = pagerState.currentPage + + Scaffold(topBar = { + Column { + bruhcollective.itaysonlab.jetispot.ui.shared.evo.SmallTopAppBar(title = { + Text("Your Library") + }, navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.Default.AccountCircle, null) + } + }, actions = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.Default.Search, null) + } + + IconButton(onClick = { navController.navigate("library/debug") }) { + Icon(Icons.Default.BugReport, null) + } + }, contentPadding = PaddingValues(top = with(LocalDensity.current) { WindowInsets.statusBars.getTop(LocalDensity.current).toDp() })) + TabRow(selectedTabIndex = currentTabIndex, indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier + .tabIndicatorOffset(tabPositions[currentTabIndex]) + .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) + ) + }, divider = {}) { + viewModel.sources.forEachIndexed { index, item -> + Tab(selected = currentTabIndex == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, + text = { Text(text = item.title) }) + } + } + } + }) { padding -> + HorizontalPager( // 4. + count = viewModel.sources.size, + state = pagerState, + ) { tabIndex -> + YourLibraryRenderer(navController, viewModel.sources[tabIndex]) + } + } +} + +class YourLibraryContainerViewModel: ViewModel() { + val sources = listOf(YourLibrarySource.Default, YourLibrarySource.Albums, YourLibrarySource.Artists) +} + +@SuppressLint("ComposableNaming") +sealed class YourLibrarySource ( + val title: String +) { + private val contentState = mutableStateOf(State.Loading) + + suspend fun load(repository: SpCollectionManager) { + contentState.value = try { + State.Loaded(getData(repository)) + } catch (e: Exception) { + State.Error(e) + } + } + + @Composable + fun render(navController: NavController) { + when (val state = contentState.value) { + is State.Loaded<*> -> { + LazyColumn { + items(state.data) { item -> + render(navController, item as T) + } + } + } + + is State.Error -> { + PagingErrorPage(onReload = { }, modifier = Modifier.fillMaxSize()) + } + + State.Loading -> { + PagingLoadingPage(Modifier.fillMaxSize()) + } + } + } + + @Composable abstract fun render(navController: NavController, item: T) + abstract suspend fun getData(repository: SpCollectionManager): List + + sealed class State { + class Loaded (val data: List) : State() + class Error(val error: Exception) : State() + object Loading : State() + } + + object Default: YourLibrarySource("All") { + @Composable + override fun render(navController: NavController, item: CollectionAlbum) { + + } + + override suspend fun getData(repository: SpCollectionManager) = listOf() + } + + object Albums: YourLibrarySource("Albums") { + @Composable + override fun render(navController: NavController, item: CollectionAlbum) { + Row( + Modifier + .clickable { + navController.navigate(item.uri) + } + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp)) { + AsyncImage(model = "https://i.scdn.co/image/${item.picture}", contentDescription = null, modifier = Modifier + .size(64.dp)) + + Column(Modifier.padding(start = 16.dp).align(Alignment.CenterVertically)) { + Text(text = item.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = item.rawArtistsData.split("|").joinToString { it.split("=")[1] }, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) + } + } + } + + override suspend fun getData(repository: SpCollectionManager) = repository.albums() + } + + object Artists: YourLibrarySource("Artists") { + @Composable + override fun render(navController: NavController, item: CollectionArtist) { + Row( + Modifier + .clickable { + navController.navigate(item.uri) + } + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp)) { + AsyncImage(model = "https://i.scdn.co/image/${item.picture}", contentDescription = null, modifier = Modifier + .size(56.dp) + .clip( + CircleShape + )) + Text(text = item.name, modifier = Modifier + .padding(start = 16.dp) + .align(Alignment.CenterVertically)) + } + } + + override suspend fun getData(repository: SpCollectionManager) = repository.artists() + } +} \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/yourlibrary/YourLibraryRenderer.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/yourlibrary/YourLibraryRenderer.kt new file mode 100644 index 00000000..f66e8056 --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/yourlibrary/YourLibraryRenderer.kt @@ -0,0 +1,39 @@ +package bruhcollective.itaysonlab.jetispot.ui.screens.yourlibrary + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.navigation.NavController +import bruhcollective.itaysonlab.jetispot.core.collection.SpCollectionManager +import bruhcollective.itaysonlab.jetispot.core.collection.db.model2.CollectionModel +import bruhcollective.itaysonlab.jetispot.ui.screens.dac.DacViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Composable +fun YourLibraryRenderer( + navController: NavController, + source: YourLibrarySource<*>, + viewModel: YourLibraryRendererViewModel = hiltViewModel() +) { + LaunchedEffect(Unit) { + launch { + viewModel.load(source) + } + } + + source.render(navController) +} + +@HiltViewModel +class YourLibraryRendererViewModel @Inject constructor( + private val collectionManager: SpCollectionManager +): ViewModel() { + suspend fun load(source: YourLibrarySource<*>) { + source.load(collectionManager) + } +} \ No newline at end of file diff --git a/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/yourlibrary/debug/YourLibraryDebugScreen.kt b/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/yourlibrary/debug/YourLibraryDebugScreen.kt new file mode 100644 index 00000000..5954a49b --- /dev/null +++ b/app/src/main/java/bruhcollective/itaysonlab/jetispot/ui/screens/yourlibrary/debug/YourLibraryDebugScreen.kt @@ -0,0 +1,71 @@ +package bruhcollective.itaysonlab.jetispot.ui.screens.yourlibrary.debug + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.navigation.NavController +import bruhcollective.itaysonlab.jetispot.core.collection.SpCollectionManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun YourLibraryDebugScreen( + navController: NavController, + viewModel: YourLibraryDebugScreenViewModel = hiltViewModel() +) { + val scrollBehavior = remember { TopAppBarDefaults.enterAlwaysScrollBehavior() } + val scope = rememberCoroutineScope() + + val items = listOf( + "Rescan collection" to { scope.launch { viewModel.rescan() }} + ) + + Scaffold(topBar = { + bruhcollective.itaysonlab.jetispot.ui.shared.evo.LargeTopAppBar(title = { + Text("Your Library: debugging") + }, navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.Default.ArrowBack, null) + } + }, contentPadding = PaddingValues(top = with(LocalDensity.current) { WindowInsets.statusBars.getTop( + LocalDensity.current).toDp() }), scrollBehavior = scrollBehavior) + }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) { padding -> + LazyColumn( + Modifier + .fillMaxHeight() + .padding(padding)) { + items(items) { item -> + Column(modifier = Modifier + .fillMaxWidth() + .clickable { item.second() } + .padding(16.dp)) { + Text(text = item.first, color = MaterialTheme.colorScheme.onBackground, fontSize = 18.sp) + } + } + } + } +} + +@HiltViewModel +class YourLibraryDebugScreenViewModel @Inject constructor( + private val collectionManager: SpCollectionManager +): ViewModel() { + suspend fun rescan() = collectionManager.scan() +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 46ec3a23..2bffed1b 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ buildscript { ext { compose_version = '1.2.0-alpha07' media2_version = "1.2.1" + roomVersion = "2.4.2" } }