diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..8aec1d0 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,192 @@ +import com.github.triplet.gradle.androidpublisher.ReleaseStatus + +plugins { + id 'com.android.application' + id 'com.google.dagger.hilt.android' + id 'kotlin-kapt' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.10' + id 'com.google.gms.google-services' + id 'com.google.firebase.crashlytics' + id 'dagger.hilt.android.plugin' + id 'com.github.triplet.play' version '3.7.0' + id "io.sentry.android.gradle" version "3.4.2" +} + +def appKeyStoreFile = "../upload_key.jks" +//def appKeyStorePassword = System.env.KEY_STORE_PASSWORD +//def appKeyAlias = System.env.KEY_ALIAS +//def appKeyPassword = System.env.KEY_PASSWORD +def appKeyStorePassword = System.env.KEY_STORE_PASSWORD +def appKeyAlias = System.env.KEY_ALIAS +def appKeyPassword = System.env.KEY_PASSWORD + +sentry { + includeProguardMapping = true + autoUploadProguardMapping = true + experimentalGuardsquareSupport = true +} + +android { + namespace 'io.musicorum.mobile' + compileSdk 34 + + + signingConfigs { + release { + storeFile file(appKeyStoreFile) + storePassword appKeyStorePassword + keyAlias appKeyAlias + keyPassword appKeyPassword + } + } + + + lintOptions { + disable 'MissingTranslation' + disable 'NullSafeMutableLiveData' + } + + defaultConfig { + applicationId "io.musicorum.mobile" + minSdk 28 + targetSdk 34 + versionCode 59 + versionName "1.17.0-pre-release" + //compileSdkPreview = "UpsideDownCake" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary true + } + + def tokensFile = rootProject.file("tokens.properties") + def tokens = new Properties() + tokens.load(new FileInputStream(tokensFile)) + buildConfigField("String", "LASTFM_API_KEY", tokens["LASTFM_API_KEY"]) + buildConfigField("String", "LASTFM_SECRET", tokens["LASTFM_SECRET"]) + buildConfigField("String", "MUSICORUM_API_KEY", tokens["MUSICORUM_API_KEY"]) + } + + buildTypes { + release { + def releaseTokensFile = rootProject.file("tokens.release.properties") + def releaseTokens = new Properties() + releaseTokens.load(new FileInputStream(releaseTokensFile)) + + signingConfig signingConfigs.release + buildConfigField("String", "LASTFM_API_KEY", releaseTokens["LASTFM_API_KEY"]) + buildConfigField("String", "LASTFM_SECRET", releaseTokens["LASTFM_SECRET"]) + buildConfigField("String", "MUSICORUM_API_KEY", releaseTokens["MUSICORUM_API_KEY"]) + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + ndk { + debugSymbolLevel = "SYMBOL_TABLE" + } + } + debug { + versionNameSuffix '-debug' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion '1.4.2' + } + packagingOptions { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } +} + +play { + double fraction = 100 + def googleServiceAccountKeyFile = "../google_sa_key.json" + track.set("internal") + serviceAccountCredentials.set(file(googleServiceAccountKeyFile)) + releaseStatus.set(ReleaseStatus.COMPLETED) + play.userFraction.set(fraction) +} + +dependencies { + implementation 'com.github.skydoves:balloon-compose:1.5.2' + implementation 'androidx.work:work-runtime-ktx:2.7.1' + implementation "androidx.paging:paging-compose:3.2.0-rc01" + implementation 'com.github.crowdin.mobile-sdk-android:sdk:1.5.7' + implementation platform('androidx.compose:compose-bom:2022.11.00') + implementation 'com.google.android.play:app-update:2.1.0' + implementation 'com.google.android.play:app-update-ktx:2.1.0' + implementation 'org.jsoup:jsoup:1.15.3' + + //implementation 'io.sentry:sentry-android:6.14.0' + //implementation 'io.sentry:sentry-compose-android:6.14.0' + + //def room_version = "2.4.3" + //implementation "androidx.room:room-runtime:$room_version" + //annotationProcessor "androidx.room:room-compiler:$room_version" + //kapt "androidx.room:room-compiler:$room_version" + + implementation "com.google.dagger:hilt-android:2.44.2" + implementation 'androidx.core:core-ktx:1.10.0' + kapt "com.google.dagger:hilt-compiler:2.44.2" + implementation 'androidx.hilt:hilt-navigation-compose:1.0.0' + + // implementation "com.google.accompanist:accompanist-permissions:0.29.0-alpha17" + implementation 'com.google.firebase:firebase-messaging-ktx:23.1.2' + implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1" + implementation 'app.rive:rive-android:4.0.0' + implementation "androidx.startup:startup-runtime:1.1.1" + implementation "com.google.accompanist:accompanist-swiperefresh:0.27.0" + implementation 'com.google.firebase:firebase-crashlytics-ktx' + implementation 'com.google.firebase:firebase-analytics-ktx' + implementation 'com.google.firebase:firebase-config-ktx' + implementation platform('com.google.firebase:firebase-bom:31.0.2') + implementation 'androidx.browser:browser:1.4.0' + implementation "io.ktor:ktor-client-core:$ktor_version" + implementation "io.ktor:ktor-client-android:$ktor_version" + implementation "io.ktor:ktor-client-cio:$ktor_version" + implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version" + implementation "io.ktor:ktor-client-content-negotiation:$ktor_version" + implementation "io.ktor:ktor-serialization-kotlinx-xml:$ktor_version" + implementation 'com.google.accompanist:accompanist-systemuicontroller:0.26.5-rc' + implementation 'androidx.navigation:navigation-compose:2.7.1' + implementation "androidx.compose.material:material-icons-extended" + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' + implementation "io.ktor:ktor-client-logging:$ktor_version" + implementation "androidx.compose.runtime:runtime-livedata" + implementation 'androidx.palette:palette-ktx:1.0.0' + implementation 'com.github.ajalt.colormath:colormath:3.2.1' + implementation 'com.github.ajalt.colormath.extensions:colormath-ext-jetpack-compose:3.2.1' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' + + + implementation 'androidx.core:core-ktx:1.10.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' + implementation 'androidx.activity:activity-compose:1.7.1' + implementation "androidx.compose.ui:ui" + implementation "androidx.compose.ui:ui-tooling-preview" + implementation 'androidx.compose.material3:material3:1.2.0-alpha03' + implementation 'com.google.accompanist:accompanist-placeholder-material:0.26.5-rc' + implementation 'io.coil-kt:coil-compose:2.4.0' + implementation 'androidx.datastore:datastore-preferences:1.0.0' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" + debugImplementation "androidx.compose.ui:ui-tooling" + debugImplementation "androidx.compose.ui:ui-test-manifest" +} + +kapt { + correctErrorTypes true +} diff --git a/app/src/main/java/io/musicorum/mobile/MainActivity.kt b/app/src/main/java/io/musicorum/mobile/MainActivity.kt index f0734b3..7facf1f 100644 --- a/app/src/main/java/io/musicorum/mobile/MainActivity.kt +++ b/app/src/main/java/io/musicorum/mobile/MainActivity.kt @@ -249,6 +249,7 @@ class MainActivity : ComponentActivity() { "scrobbling" -> true "profile" -> true "charts" -> true + "discover" -> true else -> false } diff --git a/app/src/main/java/io/musicorum/mobile/components/AlbumListItem.kt b/app/src/main/java/io/musicorum/mobile/components/AlbumListItem.kt new file mode 100644 index 0000000..77d9d3c --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/components/AlbumListItem.kt @@ -0,0 +1,42 @@ +package io.musicorum.mobile.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.musicorum.mobile.LocalNavigation +import io.musicorum.mobile.coil.PlaceholderType +import io.musicorum.mobile.coil.defaultImageRequestBuilder +import io.musicorum.mobile.router.Routes +import io.musicorum.mobile.serialization.entities.Album +import io.musicorum.mobile.ui.theme.ContentSecondary + +@Composable +fun AlbumListItem(album: Album) { + val model = defaultImageRequestBuilder( + url = album.bestImageUrl, + placeholderType = PlaceholderType.ALBUM + ) + val nav = LocalNavigation.current + + ListItem( + headlineContent = { Text(album.name) }, + supportingContent = { Text(album.artist ?: "Unknown Artist", color = ContentSecondary) }, + leadingContent = { + AsyncImage( + model = model, contentDescription = null, modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .size(46.dp) + ) + }, + modifier = Modifier.clickable { + nav?.navigate(Routes.album(album)) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/components/ArtistListItem.kt b/app/src/main/java/io/musicorum/mobile/components/ArtistListItem.kt new file mode 100644 index 0000000..3e4b4a6 --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/components/ArtistListItem.kt @@ -0,0 +1,39 @@ +package io.musicorum.mobile.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.musicorum.mobile.LocalNavigation +import io.musicorum.mobile.coil.PlaceholderType +import io.musicorum.mobile.coil.defaultImageRequestBuilder +import io.musicorum.mobile.router.Routes +import io.musicorum.mobile.serialization.entities.Artist + +@Composable +fun ArtistListItem(artist: Artist) { + val model = defaultImageRequestBuilder( + url = artist.bestImageUrl, + placeholderType = PlaceholderType.ARTIST + ) + val nav = LocalNavigation.current + ListItem( + headlineContent = { Text(artist.name) }, + leadingContent = { + AsyncImage( + model = model, contentDescription = null, modifier = Modifier + .clip(CircleShape) + .size(46.dp) + ) + }, + modifier = Modifier.clickable { + nav?.navigate(Routes.artist(artist.name)) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/components/TrackItem.kt b/app/src/main/java/io/musicorum/mobile/components/TrackItem.kt index b7f1ac7..152686d 100644 --- a/app/src/main/java/io/musicorum/mobile/components/TrackItem.kt +++ b/app/src/main/java/io/musicorum/mobile/components/TrackItem.kt @@ -2,12 +2,19 @@ package io.musicorum.mobile.components import android.text.format.DateUtils import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -19,19 +26,20 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage -import io.ktor.http.* +import io.ktor.http.encodeURLPathPart import io.musicorum.mobile.LocalNavigation import io.musicorum.mobile.R import io.musicorum.mobile.coil.defaultImageRequestBuilder import io.musicorum.mobile.serialization.NavigationTrack +import io.musicorum.mobile.serialization.SearchTrack import io.musicorum.mobile.serialization.entities.Track +import io.musicorum.mobile.ui.theme.ContentSecondary import io.musicorum.mobile.ui.theme.KindaBlack import io.musicorum.mobile.ui.theme.Typography import io.musicorum.mobile.viewmodels.TrackRowViewModel import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TrackItem( track: Track?, @@ -107,5 +115,50 @@ fun TrackItem( } }, + ) +} + +@Composable +fun TrackItem( + track: SearchTrack? +) { + if (track == null) return + val partialTrack = NavigationTrack(track.name.encodeURLPathPart(), track.artist) + val dest = Json.encodeToString(partialTrack) + val listColors = ListItemDefaults.colors( + containerColor = KindaBlack + ) + val nav = LocalNavigation.current!! + + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { nav.navigate("track/$dest") }, + headlineContent = { + Text( + text = track.name, + style = Typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + colors = listColors, + supportingContent = { + Text( + text = track.artist, + style = Typography.bodyMedium, + color = ContentSecondary + ) + }, + leadingContent = { + AsyncImage( + model = defaultImageRequestBuilder(url = track.images[0].url), + contentDescription = null, + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(6.dp)) + .aspectRatio(1f) + ) + }, ) } \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/AlbumEndpoint.kt b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/AlbumEndpoint.kt index 98c01fb..2cd345d 100644 --- a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/AlbumEndpoint.kt +++ b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/AlbumEndpoint.kt @@ -5,6 +5,7 @@ import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.http.isSuccess import io.musicorum.mobile.ktor.KtorConfiguration +import io.musicorum.mobile.serialization.SearchResponse import io.musicorum.mobile.serialization.entities.Album import kotlinx.serialization.Serializable @@ -22,6 +23,19 @@ object AlbumEndpoint { null } } + + suspend fun search(query: String, limit: Int? = null, page: Int? = null): SearchResponse? { + val res = KtorConfiguration.lastFmClient.get { + parameter("method", "album.search") + parameter("album", query) + parameter("limit", limit) + parameter("page", page) + } + + return if (res.status.isSuccess()) { + res.body() + } else null + } } @Serializable diff --git a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/ArtistEndpoint.kt b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/ArtistEndpoint.kt index ab2561a..6d9d79f 100644 --- a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/ArtistEndpoint.kt +++ b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/ArtistEndpoint.kt @@ -5,6 +5,7 @@ import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.http.isSuccess import io.musicorum.mobile.ktor.KtorConfiguration +import io.musicorum.mobile.serialization.SearchResponse import io.musicorum.mobile.serialization.TopAlbumsResponse import io.musicorum.mobile.serialization.entities.Artist import io.musicorum.mobile.serialization.entities.TopTracks @@ -52,6 +53,19 @@ object ArtistEndpoint { return null } } + + suspend fun search(query: String, limit: Int? = null, page: Int? = null): SearchResponse? { + val res = KtorConfiguration.lastFmClient.get { + parameter("method", "artist.search") + parameter("artist", query) + parameter("limit", limit) + parameter("page", page) + } + + return if (res.status.isSuccess()) { + res.body() + } else null + } } @Serializable diff --git a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/TrackEndpoint.kt b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/TrackEndpoint.kt index d2a788a..900c56b 100644 --- a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/TrackEndpoint.kt +++ b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/TrackEndpoint.kt @@ -9,6 +9,7 @@ import io.ktor.client.request.post import io.ktor.http.isSuccess import io.musicorum.mobile.ktor.KtorConfiguration import io.musicorum.mobile.serialization.BaseIndividualTrack +import io.musicorum.mobile.serialization.SearchResponse import io.musicorum.mobile.serialization.SimilarTrack import io.musicorum.mobile.serialization.entities.Track import io.musicorum.mobile.userData @@ -77,4 +78,18 @@ object TrackEndpoint { } } + suspend fun search(query: String, limit: Int? = null, page: Int? = null, artist: String? = null): SearchResponse? { + val res = KtorConfiguration.lastFmClient.get { + parameter("method", "track.search") + parameter("track", query) + parameter("artist", artist) + parameter("limit", limit) + parameter("page", page) + } + + return if (res.status.isSuccess()) { + res.body() + } else null + } + } diff --git a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/musicorum/MusicorumTrackEndpoint.kt b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/musicorum/MusicorumTrackEndpoint.kt index f4d570b..1734ebf 100644 --- a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/musicorum/MusicorumTrackEndpoint.kt +++ b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/musicorum/MusicorumTrackEndpoint.kt @@ -13,6 +13,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.http.isSuccess import io.musicorum.mobile.ktor.KtorConfiguration +import io.musicorum.mobile.serialization.SearchTrack import io.musicorum.mobile.serialization.entities.Track import io.musicorum.mobile.serialization.musicorum.TrackResponse import kotlinx.serialization.builtins.ListSerializer @@ -55,6 +56,35 @@ object MusicorumTrackEndpoint { } + @JvmName("fetchTracks1") + suspend fun fetchTracks(tracks: List): List { + if (tracks.isEmpty()) return emptyList() + val trackList: MutableList = mutableListOf() + tracks.forEach { t -> trackList.add(BodyTrack(t.name, t.artist)) } + val res: HttpResponse = KtorConfiguration.musicorumClient.post { + url("/v2/resources/tracks") + contentType(ContentType.Application.Json) + setBody(Body(trackList.toList())) + } + + /* Re-run requests that are 201 -- Experimental */ + if (res.status == HttpStatusCode.Created) { + val requestBuilder = HttpRequestBuilder().takeFrom(res.request) + val newRes = KtorConfiguration.musicorumClient.post(requestBuilder) + return if (res.status.isSuccess()) { + json.decodeFromString( + ListSerializer(TrackResponse.serializer().nullable), + newRes.bodyAsText() + ) + } else emptyList() + } + + return if (res.status.isSuccess()) { + json.decodeFromString(ListSerializer(TrackResponse.serializer()), res.bodyAsText()) + } else emptyList() + + } + @kotlinx.serialization.Serializable private data class Body( val tracks: List diff --git a/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt b/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt index 7c30c30..4d4abe9 100644 --- a/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt +++ b/app/src/main/java/io/musicorum/mobile/router/BottomNavbar.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.rounded.BarChart import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.QueueMusic +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem @@ -26,9 +27,10 @@ import java.util.Locale @Composable fun BottomNavBar(nav: NavHostController) { - val items = listOf("Home", "Scrobbling", "Charts", "Profile") + val items = listOf("Home", "Discover", "Scrobbling", "Charts", "Profile") val icons = listOf( Icons.Rounded.Home, + Icons.Rounded.Search, Icons.Rounded.QueueMusic, Icons.Rounded.BarChart, Icons.Rounded.Person diff --git a/app/src/main/java/io/musicorum/mobile/router/Routes.kt b/app/src/main/java/io/musicorum/mobile/router/Routes.kt index b355e08..d6dcb2a 100644 --- a/app/src/main/java/io/musicorum/mobile/router/Routes.kt +++ b/app/src/main/java/io/musicorum/mobile/router/Routes.kt @@ -2,7 +2,13 @@ package io.musicorum.mobile.router import io.musicorum.mobile.models.FetchPeriod import io.musicorum.mobile.models.ResourceEntity +import io.musicorum.mobile.serialization.entities.Album +import io.musicorum.mobile.serialization.entities.Artist import io.musicorum.mobile.utils.PeriodResolver +import io.musicorum.mobile.views.individual.PartialAlbum +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement object Routes { fun user(username: String) = "user/$username" @@ -10,6 +16,10 @@ object Routes { const val home = "home" const val mostListened = "mostListened" fun album(data: String) = "album/$data" + fun album(album: Album): String { + val partial = PartialAlbum(album.name, album.artist ?: "unknown") + return "album/${Json.encodeToString(partial)}" + } const val settings = "settings" const val login = "login" const val scrobbleSettings = "settings/scrobble" diff --git a/app/src/main/java/io/musicorum/mobile/serialization/SearchResponse.kt b/app/src/main/java/io/musicorum/mobile/serialization/SearchResponse.kt new file mode 100644 index 0000000..c2289b7 --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/serialization/SearchResponse.kt @@ -0,0 +1,9 @@ +package io.musicorum.mobile.serialization + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class SearchResponse( + val results: JsonObject +) diff --git a/app/src/main/java/io/musicorum/mobile/serialization/SearchTrack.kt b/app/src/main/java/io/musicorum/mobile/serialization/SearchTrack.kt new file mode 100644 index 0000000..09047f5 --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/serialization/SearchTrack.kt @@ -0,0 +1,12 @@ +package io.musicorum.mobile.serialization + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SearchTrack( + val name: String, + val artist: String, + @SerialName("image") + val images: List +) diff --git a/app/src/main/java/io/musicorum/mobile/viewmodels/DiscoverVm.kt b/app/src/main/java/io/musicorum/mobile/viewmodels/DiscoverVm.kt new file mode 100644 index 0000000..d35cae8 --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/viewmodels/DiscoverVm.kt @@ -0,0 +1,108 @@ +package io.musicorum.mobile.viewmodels + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.musicorum.mobile.ktor.endpoints.AlbumEndpoint +import io.musicorum.mobile.ktor.endpoints.ArtistEndpoint +import io.musicorum.mobile.ktor.endpoints.TrackEndpoint +import io.musicorum.mobile.ktor.endpoints.musicorum.MusicorumAlbumEndpoint +import io.musicorum.mobile.ktor.endpoints.musicorum.MusicorumArtistEndpoint +import io.musicorum.mobile.ktor.endpoints.musicorum.MusicorumTrackEndpoint +import io.musicorum.mobile.serialization.SearchTrack +import io.musicorum.mobile.serialization.entities.Album +import io.musicorum.mobile.serialization.entities.Artist +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject + +class DiscoverVm : ViewModel() { + private val json = Json { ignoreUnknownKeys = true } + val query = MutableLiveData("") + val busy = MutableLiveData(false) + val trackResults = MutableLiveData>(emptyList()) + val albumResults = MutableLiveData>(emptyList()) + val artistResults = MutableLiveData>(emptyList()) + + + fun updateQuery(value: String) { + query.value = value + } + + fun search() { + if (query.value!!.isEmpty()) return + busy.value = true + viewModelScope.launch { + awaitAll( + async { + val res = TrackEndpoint.search(query.value!!) + res?.let { + val decoded = + res.results["trackmatches"]?.jsonObject?.get("track") + decoded?.let { + val list = json.decodeFromJsonElement( + ListSerializer(SearchTrack.serializer()), + it + ) + val musRes = MusicorumTrackEndpoint.fetchTracks(list) + if (musRes.isNotEmpty()) { + list.onEachIndexed { i, t -> + t.images[0].url = + musRes.getOrNull(i)?.bestResource?.bestImageUrl ?: "" + } + } + trackResults.value = list + } + } + }, + + async { + val res = AlbumEndpoint.search(query.value!!) + res?.let { + val decoded = res.results["albummatches"]?.jsonObject?.get("album") + decoded?.let { + val list = json.decodeFromJsonElement( + ListSerializer(Album.serializer()), + decoded + ) + val musRes = MusicorumAlbumEndpoint.fetchAlbums(list) + if (musRes.isNotEmpty()) { + list.onEachIndexed { index, album -> + album.bestImageUrl = + musRes[index]?.bestResource?.bestImageUrl ?: "" + } + } + albumResults.value = list + } + } + }, + + async { + val res = ArtistEndpoint.search(query.value!!) + res?.let { + val decoded = res.results["artistmatches"]?.jsonObject?.get("artist") + decoded?.let { + val list = json.decodeFromJsonElement( + ListSerializer(Artist.serializer()), + decoded + ) + val musRes = MusicorumArtistEndpoint.fetchArtist(list) + if (musRes.isNotEmpty()) { + list.onEachIndexed { index, artist -> + artist.bestImageUrl = + musRes[index].bestResource?.bestImageUrl ?: "" + } + } + artistResults.value = list + } + } + } + ) + busy.value = false + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/views/Discover.kt b/app/src/main/java/io/musicorum/mobile/views/Discover.kt index 7836488..66fa7e4 100644 --- a/app/src/main/java/io/musicorum/mobile/views/Discover.kt +++ b/app/src/main/java/io/musicorum/mobile/views/Discover.kt @@ -1,29 +1,136 @@ package io.musicorum.mobile.views import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Album +import androidx.compose.material.icons.rounded.Audiotrack +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.DockedSearchBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import io.musicorum.mobile.R +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import io.musicorum.mobile.components.AlbumListItem +import io.musicorum.mobile.components.ArtistListItem +import io.musicorum.mobile.components.CenteredLoadingSpinner +import io.musicorum.mobile.components.TrackItem +import io.musicorum.mobile.ui.theme.ContentSecondary import io.musicorum.mobile.ui.theme.KindaBlack +import io.musicorum.mobile.ui.theme.LighterGray +import io.musicorum.mobile.ui.theme.Typography +import io.musicorum.mobile.viewmodels.DiscoverVm +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun Discover() { - Row( - Modifier - .fillMaxSize() - .background(KindaBlack), - verticalAlignment = Alignment.CenterVertically +fun Discover(viewModel: DiscoverVm = viewModel()) { + val query = viewModel.query.observeAsState("").value + val presentResults = rememberSaveable { mutableStateOf(false) } + val artists = viewModel.artistResults.observeAsState(emptyList()).value + val tracks = viewModel.trackResults.observeAsState(emptyList()).value + val albums = viewModel.albumResults.observeAsState(emptyList()).value + val searchBarColors = SearchBarDefaults.colors( + containerColor = LighterGray + ) + val busy = viewModel.busy.observeAsState(false).value + + + Column(modifier = Modifier + .padding(vertical = 20.dp) + .fillMaxSize() + .background(KindaBlack) + .verticalScroll(rememberScrollState()) ) { Text( - stringResource(id = R.string.section_coming_soon), - textAlign = TextAlign.Center + "Discover", + style = Typography.displaySmall, + modifier = Modifier.padding(start = 15.dp) ) + + DockedSearchBar( + query = query, + onQueryChange = { viewModel.updateQuery(it) }, + onSearch = { + viewModel.search() + presentResults.value = true + }, + active = false, + onActiveChange = {}, + colors = searchBarColors, + modifier = Modifier + .padding(top = 10.dp) + .align(CenterHorizontally), + placeholder = { Text("Search on Last.fm...") }, + leadingIcon = { Icon(Icons.Rounded.Search, null) } + ) {} + + if (!presentResults.value) return + + if (busy) { + CenteredLoadingSpinner() + return + } + + Header(title = "Tracks", results = tracks.size, icon = Icons.Rounded.Audiotrack) + if (tracks.isEmpty()) { + Text("No results") + } else { + tracks.take(4).forEach { + TrackItem(track = it) + } + } + + Header(title = "Albums", results = albums.size, icon = Icons.Outlined.Album) + if (albums.isEmpty()) { + Text("No results") + } else { + albums.take(4).forEach { + AlbumListItem(it) + } + } + + Header(title = "Artists", results = artists.size, icon = Icons.Rounded.Star) + if (artists.isEmpty()) { + Text("No results") + } else { + artists.take(4).forEach { + ArtistListItem(artist = it) + } + } } +} -} \ No newline at end of file +@Composable +private fun Header(title: String, results: Int, icon: ImageVector) { + ListItem( + headlineContent = { Text(title, style = Typography.headlineSmall) }, + supportingContent = { + Text( + text = "$results results", + style = Typography.bodyMedium, + color = ContentSecondary + ) + }, + leadingContent = { Icon(icon, null, tint = Color.White) }, + //trailingContent = { Icon(Icons.Rounded.ChevronRight, null, tint = Color.White) } + //TODO: individual pages on chevron click + ) +} diff --git a/app/src/main/java/io/musicorum/mobile/views/individual/Album.kt b/app/src/main/java/io/musicorum/mobile/views/individual/Album.kt index 3e6f2b7..fdb0a5b 100644 --- a/app/src/main/java/io/musicorum/mobile/views/individual/Album.kt +++ b/app/src/main/java/io/musicorum/mobile/views/individual/Album.kt @@ -42,7 +42,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") -@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun Album( albumData: String?,