diff --git a/app/build.gradle b/app/build.gradle
index d0c56872..3aaff78e 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -204,6 +204,7 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'com.squareup.okhttp3:mockwebserver:5.0.0-alpha.11'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
+ testImplementation 'androidx.arch.core:core-testing:2.2.0'
// Mockito framework
testImplementation "org.mockito:mockito-core:5.3.1"
@@ -217,6 +218,7 @@ dependencies {
androidTestImplementation platform('androidx.compose:compose-bom:2023.03.00')
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
+ androidTestImplementation "androidx.work:work-testing:$work_version"
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.arch.core:core-testing:2.2.0'
diff --git a/app/src/androidTest/java/org/listenbrainz/android/YearInMusicActivityTest.kt b/app/src/androidTest/java/org/listenbrainz/android/YearInMusicActivityTest.kt
index 799a66d2..f656f25a 100644
--- a/app/src/androidTest/java/org/listenbrainz/android/YearInMusicActivityTest.kt
+++ b/app/src/androidTest/java/org/listenbrainz/android/YearInMusicActivityTest.kt
@@ -62,40 +62,34 @@ class YearInMusicActivityTest {
}
rule.onNodeWithText("Top Albums of 2022").assertExists()
- nextPage()
+ nextPage(scrollToEnd = false)
verifyExistence(R.string.tt_yim_charts_heading)
- scrollToEnd(R.string.tt_yim_charts_parent)
nextPage()
verifyExistence(R.string.tt_yim_statistics_heading)
nextPage()
verifyExistence(R.string.tt_yim_recommended_playlists_heading)
- scrollToEnd(R.string.tt_yim_recommended_playlists_parent)
- scrollToEnd(R.string.tt_yim_recommended_playlists_parent)
nextPage()
verifyExistence(R.string.tt_yim_discover_heading)
- scrollToEnd(R.string.tt_yim_discover_parent)
nextPage()
verifyExistence(R.string.tt_yim_endgame_heading)
}
- private fun scrollToEnd(@StringRes stringRes: Int){
- rule.onNodeWithTag(activity.getString(stringRes)).performTouchInput {
- down(bottomRight)
- moveTo(topRight)
- up()
- }
- }
-
private fun verifyExistence(@StringRes stringRes: Int){
rule.onNodeWithTag(activity.getString(stringRes)).assertExists()
}
- private fun nextPage(){
- rule.onNodeWithTag(activity.getString(R.string.tt_yim_next_button)).performClick()
+ private fun nextPage(scrollToEnd: Boolean = true){
+ rule.waitForIdle()
+ rule.onNodeWithTag(activity.getString(R.string.tt_yim_next_button)).apply {
+ if (scrollToEnd){
+ performScrollTo()
+ }
+ performClick()
+ }
}
}
diff --git a/app/src/androidTest/java/org/listenbrainz/android/di/TestAppModule.kt b/app/src/androidTest/java/org/listenbrainz/android/di/TestAppModule.kt
index 93e0f781..1a784f70 100644
--- a/app/src/androidTest/java/org/listenbrainz/android/di/TestAppModule.kt
+++ b/app/src/androidTest/java/org/listenbrainz/android/di/TestAppModule.kt
@@ -1,7 +1,12 @@
package org.listenbrainz.android.di
import android.content.Context
+
+import android.util.Log
+import androidx.work.Configuration
import androidx.work.WorkManager
+import androidx.work.testing.SynchronousExecutor
+import androidx.work.testing.WorkManagerTestInitHelper
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -21,11 +26,22 @@ class TestAppModule {
@Singleton
@Provides
- fun providesServiceConnection(
- @ApplicationContext context: Context,
- appPreferences: AppPreferences,
- workManager: WorkManager
- ) = BrainzPlayerServiceConnection(context, appPreferences, workManager)
+ fun providesServiceConnection(@ApplicationContext context: Context, appPreferences: AppPreferences, workManager: WorkManager): BrainzPlayerServiceConnection {
+ return BrainzPlayerServiceConnection(context, appPreferences, workManager)
+ }
+
+ @Provides
+ @Singleton
+ fun providesWorkManager(@ApplicationContext context: Context): WorkManager {
+ val config = Configuration.Builder()
+ .setMinimumLoggingLevel(Log.DEBUG)
+ .setExecutor(SynchronousExecutor())
+ .build()
+
+ // Initialize WorkManager for instrumentation tests.
+ WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
+ return WorkManager.getInstance(context)
+ }
@Singleton
@Provides
@@ -34,9 +50,5 @@ class TestAppModule {
@Singleton
@Provides
fun providesAppPreferences() : AppPreferences = MockAppPreferences()
-
- @Provides
- @Singleton
- fun providesWorkManager(@ApplicationContext context: Context): WorkManager =
- WorkManager.getInstance(context)
+
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 59823bc1..0f3ec923 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -89,11 +89,20 @@
+ android:name=".ui.screens.about.AboutActivity"
+ android:label="@string/about_title"
+ android:theme="@style/AppTheme"/>
+ android:name=".ui.screens.dashboard.DonateActivity"
+ android:label="@string/donate_title"
+ android:theme="@style/AppTheme"/>
+
+
+
+
+
diff --git a/app/src/main/java/org/listenbrainz/android/di/RemotePlayerRepositoryModule.kt b/app/src/main/java/org/listenbrainz/android/di/RemotePlayerRepositoryModule.kt
new file mode 100644
index 00000000..41c0a8f5
--- /dev/null
+++ b/app/src/main/java/org/listenbrainz/android/di/RemotePlayerRepositoryModule.kt
@@ -0,0 +1,17 @@
+package org.listenbrainz.android.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.listenbrainz.android.repository.remoteplayer.RemotePlayerRepository
+import org.listenbrainz.android.repository.remoteplayer.RemotePlayerRepositoryImpl
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RemotePlayerRepositoryModule {
+
+ @Binds
+ abstract fun bindsRemotePlayerRepository(repository: RemotePlayerRepositoryImpl?): RemotePlayerRepository?
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/listenbrainz/android/di/ServiceModule.kt b/app/src/main/java/org/listenbrainz/android/di/ServiceModule.kt
index aa6f0576..0e28ba47 100644
--- a/app/src/main/java/org/listenbrainz/android/di/ServiceModule.kt
+++ b/app/src/main/java/org/listenbrainz/android/di/ServiceModule.kt
@@ -1,5 +1,6 @@
package org.listenbrainz.android.di
+import android.content.Context
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonDeserializationContext
@@ -8,6 +9,7 @@ import com.google.gson.JsonElement
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
@@ -18,8 +20,10 @@ import org.listenbrainz.android.service.FeedService
import org.listenbrainz.android.service.ListensService
import org.listenbrainz.android.service.SocialService
import org.listenbrainz.android.service.YimService
+import org.listenbrainz.android.service.YouTubeApiService
import org.listenbrainz.android.util.Constants.LISTENBRAINZ_API_BASE_URL
import org.listenbrainz.android.util.HeaderInterceptor
+import org.listenbrainz.android.util.Utils
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.lang.reflect.Type
@@ -71,6 +75,25 @@ class ServiceModule {
constructRetrofit(appPreferences)
.create(FeedService::class.java)
+ @Singleton
+ @Provides
+ fun providesYoutubeApiService(@ApplicationContext context: Context): YouTubeApiService =
+ Retrofit.Builder()
+ .baseUrl("https://www.googleapis.com/")
+ .addConverterFactory(GsonConverterFactory.create())
+ .client( OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ val request = chain.request().newBuilder()
+ .addHeader("X-Android-Package", context.packageName)
+ .addHeader("X-Android-Cert", Utils.getSHA1(context, context.packageName) ?: "")
+ .build()
+ chain.proceed(request)
+ }
+ .build()
+ )
+ .build()
+ .create(YouTubeApiService::class.java)
+
/* YIM */
private val yimGson: Gson by lazy {
diff --git a/app/src/main/java/org/listenbrainz/android/model/Listen.kt b/app/src/main/java/org/listenbrainz/android/model/Listen.kt
index 9bad3fea..e04379a1 100644
--- a/app/src/main/java/org/listenbrainz/android/model/Listen.kt
+++ b/app/src/main/java/org/listenbrainz/android/model/Listen.kt
@@ -4,9 +4,9 @@ import com.google.gson.annotations.SerializedName
data class Listen(
@SerializedName("inserted_at") val insertedAt: String,
- @SerializedName("listened_at") val listenedAt: Int,
+ @SerializedName("listened_at") val listenedAt: Int? = null,
@SerializedName("recording_msid") val recordingMsid: String,
@SerializedName("track_metadata") val trackMetadata: TrackMetadata,
@SerializedName("user_name") val userName: String,
- @SerializedName("cover_art") var coverArt: CoverArt? = null
+ @SerializedName("cover_art") val coverArt: CoverArt? = null
)
\ No newline at end of file
diff --git a/app/src/main/java/org/listenbrainz/android/model/ListenBitmap.kt b/app/src/main/java/org/listenbrainz/android/model/ListenBitmap.kt
index 2987ba8d..91bf68c4 100644
--- a/app/src/main/java/org/listenbrainz/android/model/ListenBitmap.kt
+++ b/app/src/main/java/org/listenbrainz/android/model/ListenBitmap.kt
@@ -3,6 +3,6 @@ package org.listenbrainz.android.model
import android.graphics.Bitmap
data class ListenBitmap(
- val bitmap: Bitmap?=null,
- val id:String?=""
+ val bitmap: Bitmap? = null,
+ val id:String? = ""
)
diff --git a/app/src/main/java/org/listenbrainz/android/model/ResponseError.kt b/app/src/main/java/org/listenbrainz/android/model/ResponseError.kt
index a2b6f2d7..56a7cfba 100644
--- a/app/src/main/java/org/listenbrainz/android/model/ResponseError.kt
+++ b/app/src/main/java/org/listenbrainz/android/model/ResponseError.kt
@@ -23,6 +23,8 @@ enum class ResponseError(val genericToast: String, var actualResponse: String? =
BAD_GATEWAY(genericToast = "Error! Bad gateway."),
+ REMOTE_PLAYER_ERROR(genericToast = "Error! Could not play the requested listen."),
+
SERVICE_UNAVAILABLE(genericToast = "Server outage detected. Please try again later."),
NETWORK_ERROR(genericToast = "Network issues detected. Make sure device is connected to internet."),
diff --git a/app/src/main/java/org/listenbrainz/android/repository/remoteplayer/RemotePlayerRepository.kt b/app/src/main/java/org/listenbrainz/android/repository/remoteplayer/RemotePlayerRepository.kt
new file mode 100644
index 00000000..322014e0
--- /dev/null
+++ b/app/src/main/java/org/listenbrainz/android/repository/remoteplayer/RemotePlayerRepository.kt
@@ -0,0 +1,26 @@
+package org.listenbrainz.android.repository.remoteplayer
+
+import com.spotify.protocol.types.PlayerState
+import org.listenbrainz.android.model.ListenBitmap
+import org.listenbrainz.android.model.ResponseError
+import org.listenbrainz.android.util.Resource
+
+interface RemotePlayerRepository {
+
+ suspend fun searchYoutubeMusicVideoId(
+ trackName: String,
+ artist: String
+ ): Resource
+
+ suspend fun playOnYoutube(
+ getYoutubeMusicVideoId: suspend () -> Resource
+ ): Resource
+
+ suspend fun connectToSpotify(onError: (ResponseError) -> Unit = {})
+
+ fun disconnectSpotify()
+
+ suspend fun updateTrackCoverArt(playerState: PlayerState): ListenBitmap
+
+ fun playUri(trackId: String, onFailure: () -> Unit)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/listenbrainz/android/repository/remoteplayer/RemotePlayerRepositoryImpl.kt b/app/src/main/java/org/listenbrainz/android/repository/remoteplayer/RemotePlayerRepositoryImpl.kt
new file mode 100644
index 00000000..5dd541a7
--- /dev/null
+++ b/app/src/main/java/org/listenbrainz/android/repository/remoteplayer/RemotePlayerRepositoryImpl.kt
@@ -0,0 +1,384 @@
+package org.listenbrainz.android.repository.remoteplayer
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.os.Build
+import com.spotify.android.appremote.api.ConnectionParams
+import com.spotify.android.appremote.api.Connector
+import com.spotify.android.appremote.api.SpotifyAppRemote
+import com.spotify.android.appremote.api.error.CouldNotFindSpotifyApp
+import com.spotify.android.appremote.api.error.NotLoggedInException
+import com.spotify.android.appremote.api.error.UserNotAuthorizedException
+import com.spotify.protocol.client.Subscription
+import com.spotify.protocol.types.PlayerContext
+import com.spotify.protocol.types.PlayerState
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.onFailure
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.flow.callbackFlow
+import org.listenbrainz.android.BuildConfig
+import org.listenbrainz.android.R
+import org.listenbrainz.android.model.ListenBitmap
+import org.listenbrainz.android.model.ResponseError
+import org.listenbrainz.android.service.YouTubeApiService
+import org.listenbrainz.android.util.Constants
+import org.listenbrainz.android.util.Log
+import org.listenbrainz.android.util.Resource
+import org.listenbrainz.android.util.Utils
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+
+@Singleton
+class RemotePlayerRepositoryImpl @Inject constructor(
+ @ApplicationContext private val appContext: Context,
+ private val youtubeApiService: YouTubeApiService
+) : RemotePlayerRepository {
+
+ private var spotifyAppRemote: SpotifyAppRemote? = null
+ private var playerStateSubscription: Subscription? = null
+ private var playerContextSubscription: Subscription? = null
+ //var playerState: PlayerState? by mutableStateOf(null)
+
+ init {
+ SpotifyAppRemote.setDebugMode(BuildConfig.DEBUG)
+ }
+
+ /** Search for video ID on youtube.
+ * @return *null* in case no videos are found or an exception occurs.*/
+ override suspend fun searchYoutubeMusicVideoId(
+ trackName: String,
+ artist: String
+ ): Resource = runCatching {
+
+ val response = youtubeApiService.searchVideos(
+ part = "snippet",
+ query = "$trackName $artist",
+ type = "video",
+ videoCategoryId = "10",
+ apiKey = appContext.getString(R.string.youtubeApiKey)
+ )
+
+ return@runCatching if (response.isSuccessful) {
+ val items = response.body()?.items
+ if (!items.isNullOrEmpty()) {
+ Resource.success(items.first().id.videoId)
+ } else {
+ Resource.failure(error = ResponseError.DOES_NOT_EXIST.apply { actualResponse = "Could not find this song on youtube." })
+ }
+ } else {
+ Resource.failure(error = ResponseError.getError(response = response))
+ }
+
+ }.getOrElse { Utils.logAndReturn(it) }
+
+
+ /** @param getYoutubeMusicVideoId Use [searchYoutubeMusicVideoId] to search for video ID while passing your own coroutine dispatcher.*/
+ override suspend fun playOnYoutube(getYoutubeMusicVideoId: suspend () -> Resource): Resource {
+
+ val result = getYoutubeMusicVideoId()
+
+ return when(result.status) {
+ Resource.Status.SUCCESS -> {
+ // Play the track in the YouTube Music app
+ val trackUri = Uri.parse("https://music.youtube.com/watch?v=${result.data}")
+
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.data = trackUri
+ intent.setPackage(Constants.YOUTUBE_MUSIC_PACKAGE_NAME)
+
+ val activities = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ appContext.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(0L))
+ } else {
+ appContext.packageManager.queryIntentActivities(intent, 0)
+ }
+
+ when {
+ activities.isNotEmpty() -> {
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ appContext.startActivity(intent)
+ Resource.success(Unit)
+ }
+ else -> {
+ // Display an error message
+ Resource.failure(error = ResponseError.DOES_NOT_EXIST.apply { actualResponse = "YouTube Music is not installed to play the track." })
+ }
+ }
+ }
+
+ else -> {
+ /*
+ // Play track via Amazon Music
+ val intent = Intent()
+ val query = listen.trackMetadata.trackName + " " + listen.trackMetadata.artistName
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ intent.setClassName(
+ "com.amazon.mp3",
+ "com.amazon.mp3.activity.IntentProxyActivity"
+ )
+ intent.action = MediaStore.INTENT_ACTION_MEDIA_SEARCH
+ intent.putExtra(MediaStore.EXTRA_MEDIA_TITLE, query)
+ context.startActivity(intent)
+ */
+ Resource.failure(error = ResponseError.DOES_NOT_EXIST)
+ }
+ }
+ }
+
+
+ override suspend fun connectToSpotify(onError: (ResponseError) -> Unit) {
+ try {
+ if (spotifyAppRemote?.isConnected != true){
+ disconnectSpotify()
+ spotifyAppRemote = connectToAppRemote(
+ true,
+ spotifyClientId = appContext.getString(R.string.spotifyClientId),
+ onError
+ )
+ onConnected()
+ } else {
+ logMessage("Spotify already connected.")
+ }
+ } catch (error: Throwable) {
+ logError(error)
+ }
+ }
+
+ private suspend fun connectToAppRemote(showAuthView: Boolean, spotifyClientId: String, onError: (ResponseError) -> Unit): SpotifyAppRemote =
+ suspendCoroutine { cont: Continuation ->
+ SpotifyAppRemote.connect(
+ appContext,
+ ConnectionParams.Builder(spotifyClientId)
+ .setRedirectUri(Constants.SPOTIFY_REDIRECT_URI)
+ .showAuthView(showAuthView)
+ .build(),
+ object : Connector.ConnectionListener {
+ override fun onConnected(spotifyAppRemote: SpotifyAppRemote) {
+ Log.d("App remote Connected!")
+ cont.resume(spotifyAppRemote)
+ }
+
+ override fun onFailure(error: Throwable) {
+ if (error is CouldNotFindSpotifyApp) {
+ // Tell user that they need to install the spotify app on the phone.
+ onError(
+ ResponseError.REMOTE_PLAYER_ERROR.apply {
+ actualResponse = "Install the Spotify app in order to play songs seamlessly."
+ }
+ )
+ }
+
+ if (error is NotLoggedInException) {
+ // Tell user that they need to login in the spotify app.
+ onError(
+ ResponseError.REMOTE_PLAYER_ERROR.apply {
+ actualResponse = "Login into Spotify app in order to play songs from it."
+ }
+ )
+ }
+
+ if (error is UserNotAuthorizedException) {
+ // Explicit user authorization is required to use Spotify.
+ // The user has to complete the auth-flow to allow the app to use Spotify on their behalf.
+ onError(
+ ResponseError.REMOTE_PLAYER_ERROR.apply {
+ actualResponse = "Authorize ListenBrainz Android in order to play songs from Spotify."
+ }
+ )
+ }
+
+ // Throw exception
+ cont.resumeWithException(error)
+ }
+ }
+ )
+ }
+
+ override fun disconnectSpotify() { SpotifyAppRemote.disconnect(spotifyAppRemote) }
+
+ private fun onConnected() {
+ onSubscribedToPlayerStateButtonClicked()
+ onSubscribedToPlayerContextButtonClicked()
+ }
+
+ override suspend fun updateTrackCoverArt(playerState: PlayerState): ListenBitmap = suspendCoroutine { cont ->
+ // Get image from track
+ assertAppRemoteConnected()?.imagesApi?.getImage(playerState.track.imageUri, com.spotify.protocol.types.Image.Dimension.LARGE)
+ ?.setResultCallback { bitmapHere ->
+ cont.resume(
+ ListenBitmap(
+ bitmap = bitmapHere,
+ id = playerState.track.uri
+ )
+ )
+ }?.setErrorCallback {
+ cont.resume(
+ ListenBitmap(
+ // Fallback CA
+ bitmap = BitmapFactory.decodeResource(
+ appContext.resources,
+ R.drawable.ic_coverartarchive_logo_no_text
+ ),
+ id = playerState.track.uri
+ )
+ )
+ }
+ }
+
+ /** @param onFailure should be alternative play option to spotify and should create its own coroutine.*/
+ override fun playUri(trackId: String, onFailure: () -> Unit) {
+ assertAppRemoteConnected()?.playerApi?.play("spotify:track:${trackId}")?.setResultCallback {
+ logMessage("play command successful!") //getString(R.string.command_feedback, "play"))
+ }?.setErrorCallback{
+ errorCallback(it)
+ onFailure()
+ }
+ }
+
+ private val playerContextEventCallback = Subscription.EventCallback { playerContext ->
+
+ }
+
+ private val playerStateEventCallback = Subscription.EventCallback { playerStateHere ->
+ //playerState = playerStateHere
+ }
+
+
+ /*fun play(onPlay: () -> Unit){
+ assertAppRemoteConnected()?.playerApi?.resume()?.setResultCallback {
+ onPlay()
+ logMessage("play command successful!") //getString(R.string.command_feedback, "play"))
+ }?.setErrorCallback(errorCallback)
+ //trackProgress()
+ }*/
+
+// fun pause(onPause: () -> Unit){
+// assertAppRemoteConnected()?.playerApi?.pause()?.setResultCallback {
+// logMessage("pause command successful!") //getString(R.string.command_feedback, "play"))
+// }?.setErrorCallback(errorCallback)
+// onPause()
+// //trackProgress()
+// }
+
+ /*suspend fun trackProgress(
+ updateState: () -> Unit,
+ updateBitmap: (ListenBitmap) -> Unit,
+ bitmap: ListenBitmap
+ ) = suspendCoroutine { cont ->
+ assertAppRemoteConnected()?.playerApi?.subscribeToPlayerState()?.setEventCallback { playerState ->
+ if(bitmap.id != playerState.track.uri) {
+ updateBitmap(updateTrackCoverArt(playerState))
+ updateState()
+ cont.resume(Unit)
+ }
+ }?.setErrorCallback{
+ errorCallback(it)
+ cont.resumeWithException(it)
+ }
+
+ /*viewModelScope.launch(Dispatchers.Default) {
+ do {
+ // FIXME: Called even if spotify isn't there which leads to infinite logging.
+ state = assertAppRemoteConnected()?.playerApi?.playerState?.await()?.data
+ val pos = state?.playbackPosition?.toFloat() ?: 0f
+ val duration = state?.track?.duration ?: 1
+ if (progress.value != pos) {
+ _progress.emit(pos / duration.toFloat())
+ _songDuration.emit(duration)
+ _songCurrentPosition.emit(((pos / duration) * duration).toLong())
+ }
+ delay(900L)
+ }while (!isPaused)
+ }*/
+ }*/
+
+ /*suspend fun seekTo(pos:Float,state: PlayerState?){
+ val duration=state?.track?.duration ?: 1
+ val position=(pos*duration).toLong()
+ assertAppRemoteConnected()?.playerApi?.seekTo(position)?.setResultCallback {
+ logMessage("seek command successful!") //getString(R.string.command_feedback, "play"))
+ }?.setErrorCallback(errorCallback)
+ viewModelScope.launch(Dispatchers.Default) {
+ if (progress.value != pos) {
+ _progress.emit(pos / duration.toFloat())
+ _songDuration.emit(duration ?: 0)
+ _songCurrentPosition.emit(((pos / duration) * duration).toLong())
+ }
+ }
+ }*/
+
+ private fun onSubscribedToPlayerContextButtonClicked() {
+ playerContextSubscription = cancelAndResetSubscription(playerContextSubscription)
+ playerContextSubscription = assertAppRemoteConnected()?.playerApi
+ ?.subscribeToPlayerContext()
+ ?.setEventCallback(playerContextEventCallback)
+ ?.setErrorCallback(errorCallback) as Subscription
+ }
+
+ private fun onSubscribedToPlayerStateButtonClicked() = callbackFlow {
+
+ playerStateSubscription = cancelAndResetSubscription(playerStateSubscription)
+ playerStateSubscription = assertAppRemoteConnected()?.playerApi?.subscribeToPlayerState()
+ ?.setEventCallback{ playerState ->
+ trySendBlocking(playerState)
+ .onFailure {
+ it?.printStackTrace()
+ }
+ }
+ ?.setLifecycleCallback(
+ object : Subscription.LifecycleCallback {
+ override fun onStart() {
+ logMessage("Event: start")
+ }
+
+ override fun onStop() {
+ logMessage("Event: end")
+ }
+ }
+ )
+ ?.setErrorCallback {
+ it?.printStackTrace()
+ } as Subscription
+
+ awaitClose {
+ cancelAndResetSubscription(playerStateSubscription)
+ }
+ }
+
+ private fun cancelAndResetSubscription(subscription: Subscription?): Subscription? {
+ return subscription?.let {
+ if (!it.isCanceled) {
+ it.cancel()
+ }
+ null
+ }
+ }
+
+ private fun assertAppRemoteConnected(): SpotifyAppRemote? {
+ spotifyAppRemote?.let {
+ if (it.isConnected) {
+ return it
+ }
+ }
+ logMessage("Spotify is not Connected.") //getString(R.string.err_spotify_disconnected))
+ return null
+ }
+
+ private fun logError(throwable: Throwable) {
+ throwable.message?.let { Log.e(it) }
+ }
+
+ private fun logMessage(msg: String) {
+ Log.d(msg)
+ }
+
+ private val errorCallback = { throwable: Throwable -> logError(throwable) }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/listenbrainz/android/ui/components/ListenCardSmall.kt b/app/src/main/java/org/listenbrainz/android/ui/components/ListenCardSmall.kt
index 5dbb13e6..f9612aad 100644
--- a/app/src/main/java/org/listenbrainz/android/ui/components/ListenCardSmall.kt
+++ b/app/src/main/java/org/listenbrainz/android/ui/components/ListenCardSmall.kt
@@ -91,7 +91,7 @@ fun ListenCardSmall(
Spacer(modifier = Modifier.width(ListenBrainzTheme.paddings.coverArtAndTextGap))
- TitleAndSubtitle(title = releaseName, subtitle = artistName)
+ TitleAndSubtitle(modifier = Modifier.padding(end = 6.dp), title = releaseName, subtitle = artistName)
}
@@ -108,7 +108,7 @@ fun ListenCardSmall(
modifier = Modifier
.fillMaxWidth(trailingContentFraction)
.align(Alignment.CenterStart)
- .padding(horizontal = 6.dp)
+ .padding(end = 6.dp)
)
}
diff --git a/app/src/main/java/org/listenbrainz/android/ui/components/YimListenCard.kt b/app/src/main/java/org/listenbrainz/android/ui/components/YimListenCard.kt
index 4bef670f..e2582766 100644
--- a/app/src/main/java/org/listenbrainz/android/ui/components/YimListenCard.kt
+++ b/app/src/main/java/org/listenbrainz/android/ui/components/YimListenCard.kt
@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -45,7 +46,7 @@ fun YimListenCard(
},
shape = RoundedCornerShape(5.dp),
shadowElevation = 5.dp,
- color = ListenBrainzTheme.colorScheme.level1
+ color = Color.White
) {
Row(
modifier = Modifier
diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/AlbumScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/AlbumScreen.kt
index 85948b38..3173c29c 100644
--- a/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/AlbumScreen.kt
+++ b/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/AlbumScreen.kt
@@ -283,7 +283,7 @@ fun OnAlbumClickScreen(albumID: Long) {
ListenCardSmall(
modifier = Modifier.padding(
horizontal = ListenBrainzTheme.paddings.horizontal,
- vertical = ListenBrainzTheme.paddings.listenListVertical
+ vertical = ListenBrainzTheme.paddings.lazyListAdjacent
),
releaseName = it.title,
artistName = it.artist,
diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/ArtistScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/ArtistScreen.kt
index 18fb673f..9a33e6f9 100644
--- a/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/ArtistScreen.kt
+++ b/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/ArtistScreen.kt
@@ -369,7 +369,7 @@ fun OnArtistClickScreen(artistID: String, navigateToAlbum: (id: Long) -> Unit) {
ListenCardSmall(
modifier = Modifier.padding(
horizontal = ListenBrainzTheme.paddings.horizontal,
- vertical = ListenBrainzTheme.paddings.listenListVertical
+ vertical = ListenBrainzTheme.paddings.lazyListAdjacent
),
releaseName = it.title,
artistName = it.artist,
diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/PlaylistScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/PlaylistScreen.kt
index 36076c9c..bc569745 100644
--- a/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/PlaylistScreen.kt
+++ b/app/src/main/java/org/listenbrainz/android/ui/screens/brainzplayer/PlaylistScreen.kt
@@ -408,7 +408,7 @@ fun OnPlaylistClickScreen(playlistID: Long) {
ListenCardSmall(
modifier = Modifier.padding(
horizontal = ListenBrainzTheme.paddings.horizontal,
- vertical = ListenBrainzTheme.paddings.listenListVertical
+ vertical = ListenBrainzTheme.paddings.lazyListAdjacent
),
releaseName = it.title,
artistName = it.artist,
diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/dashboard/DashboardActivity.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/dashboard/DashboardActivity.kt
index 163a9881..e9ece378 100644
--- a/app/src/main/java/org/listenbrainz/android/ui/screens/dashboard/DashboardActivity.kt
+++ b/app/src/main/java/org/listenbrainz/android/ui/screens/dashboard/DashboardActivity.kt
@@ -172,7 +172,10 @@ class DashboardActivity : ComponentActivity() {
}
}
- SearchScreen(searchBarState = searchBarState)
+ SearchScreen(
+ isActive = searchBarState.isActive,
+ deactivate = {searchBarState.deactivate()}
+ )
}
}
diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/feed/FeedScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/feed/FeedScreen.kt
index 651af939..b6fbbe48 100644
--- a/app/src/main/java/org/listenbrainz/android/ui/screens/feed/FeedScreen.kt
+++ b/app/src/main/java/org/listenbrainz/android/ui/screens/feed/FeedScreen.kt
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -32,11 +34,14 @@ import androidx.compose.material3.ElevatedSuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
@@ -69,6 +74,13 @@ fun FeedScreen(
) {
val uiState = viewModel.uiState.collectAsState().value
+ DisposableEffect(Unit){
+ viewModel.connectToSpotify()
+ onDispose {
+ viewModel.disconnectSpotify()
+ }
+ }
+
FeedScreen(
uiState = uiState,
scrollToTopState = scrollToTopState,
@@ -80,8 +92,8 @@ fun FeedScreen(
onDropDownClick = {
},
- onPlay = {
-
+ onPlay = { event ->
+ viewModel.play(event)
}
)
}
@@ -96,7 +108,7 @@ private fun FeedScreen(
onDeleteOrHide: (event: FeedEvent, eventType: FeedEventType, parentUser: String) -> Unit,
onDropDownClick: () -> Unit,
onErrorShown: () -> Unit,
- onPlay: () -> Unit,
+ onPlay: (event: FeedEvent) -> Unit,
) {
val myFeedPagingData = uiState.myFeedState.data.eventList.collectAsLazyPagingItems()
val myFeedListState = rememberLazyListState()
@@ -108,22 +120,21 @@ private fun FeedScreen(
val similarListensListState = rememberLazyListState()
val pagerState = rememberPagerState()
- val isRefreshing = {
+ val isRefreshing =
when (pagerState.currentPage) {
- 0 -> myFeedPagingData.itemCount == 0 && myFeedPagingData.loadState.refresh is LoadState.Loading
- 1 -> followListensPagingData.itemCount == 0 && followListensPagingData.loadState.refresh is LoadState.Loading
- 2 -> similarListensPagingData.itemCount == 0 && similarListensPagingData.loadState.refresh is LoadState.Loading
+ 0 -> myFeedPagingData.loadState.refresh is LoadState.Loading
+ 1 -> followListensPagingData.loadState.refresh is LoadState.Loading
+ 2 -> similarListensPagingData.loadState.refresh is LoadState.Loading
else -> false
}
- }
+
+
val pullRefreshState = rememberPullRefreshState(
- refreshing = isRefreshing(),
+ refreshing = isRefreshing,
onRefresh = {
- when (pagerState.currentPage){
- 0 -> myFeedPagingData.refresh()
- 1 -> followListensPagingData.refresh()
- 2 -> similarListensPagingData.refresh()
- }
+ myFeedPagingData.refresh()
+ followListensPagingData.refresh()
+ similarListensPagingData.refresh()
}
)
@@ -153,34 +164,53 @@ private fun FeedScreen(
.fillMaxSize()
.pullRefresh(state = pullRefreshState)
) {
-
+
RetryButton(Modifier.align(Alignment.Center), myFeedPagingData)
- Column {
+ HorizontalPager(
+ pageCount = 3,
+ state = pagerState
+ ) { position ->
+ when (position) {
+ 0 -> MyFeed(
+ myFeedListState,
+ myFeedPagingData,
+ uiState.myFeedState,
+ onDeleteOrHide,
+ onDropDownClick,
+ onPlay
+ )
+
+ 1 -> FollowListens(
+ followListensListState,
+ followListensPagingData,
+ onDropDownClick,
+ onPlay
+ )
+
+ 2 -> SimilarListens(
+ similarListensListState,
+ similarListensPagingData,
+ onDropDownClick,
+ onPlay
+ )
+ }
+ }
+
+ Column(Modifier.fillMaxWidth()) {
+ ErrorBar(error = uiState.error, onErrorShown = onErrorShown)
NavigationChips(currentPageStateProvider = { pagerState.currentPage }) { position ->
pagerState.animateScrollToPage(position)
}
- HorizontalPager(
- pageCount = 3,
- state = pagerState
- ) { position ->
- when (position){
- 0 -> MyFeed(myFeedListState, myFeedPagingData, uiState.myFeedState, onDeleteOrHide, onDropDownClick, onPlay)
- 1 -> FollowListens(followListensListState, followListensPagingData, onDropDownClick, onPlay)
- 2 -> SimilarListens(similarListensListState, similarListensPagingData, onDropDownClick, onPlay)
- }
- }
+ PullRefreshIndicator(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ refreshing = isRefreshing,
+ contentColor = ListenBrainzTheme.colorScheme.lbSignatureInverse,
+ backgroundColor = ListenBrainzTheme.colorScheme.level1,
+ state = pullRefreshState
+ )
}
-
- ErrorBar(error = uiState.error, onErrorShown = onErrorShown)
-
- PullRefreshIndicator(
- modifier = Modifier.align(Alignment.TopCenter),
- refreshing = isRefreshing(),
- state = pullRefreshState
- )
}
-
}
@@ -191,7 +221,7 @@ private fun MyFeed(
uiState: FeedScreenUiState,
onDeleteOrHide: (event: FeedEvent, eventType: FeedEventType, parentUser: String) -> Unit,
onDropDownClick: () -> Unit,
- onPlay: () -> Unit
+ onPlay: (FeedEvent) -> Unit
) {
LazyColumn(
modifier = Modifier
@@ -200,6 +230,8 @@ private fun MyFeed(
state = listState
) {
+ item { StartingSpacer() }
+
items(count = pagingData.itemCount) { index: Int ->
pagingData[index]?.apply {
@@ -220,7 +252,7 @@ private fun MyFeed(
)
},
onDropdownClick = { onDropDownClick() },
- onClick = { onPlay() }
+ onClick = { onPlay(event) }
)
}
@@ -241,7 +273,7 @@ fun FollowListens(
listState: LazyListState,
pagingData: LazyPagingItems,
onDropDownClick: () -> Unit,
- onPlay: () -> Unit
+ onPlay: (FeedEvent) -> Unit
) {
LazyColumn(
modifier = Modifier
@@ -250,6 +282,8 @@ fun FollowListens(
state = listState,
) {
+ item { StartingSpacer() }
+
items(count = pagingData.itemCount) { index: Int ->
pagingData[index]?.apply {
@@ -283,7 +317,7 @@ fun FollowListens(
},
onDropdownIconClick = onDropDownClick,
) {
- onPlay()
+ onPlay(event)
}
}
@@ -302,7 +336,7 @@ fun SimilarListens(
listState: LazyListState,
pagingData: LazyPagingItems,
onDropDownClick: () -> Unit,
- onPlay: () -> Unit
+ onPlay: (FeedEvent) -> Unit
) {
LazyColumn(
modifier = Modifier
@@ -311,6 +345,8 @@ fun SimilarListens(
state = listState
) {
+ item { StartingSpacer() }
+
items(count = pagingData.itemCount) { index: Int ->
pagingData[index]?.apply {
@@ -341,7 +377,7 @@ fun SimilarListens(
},
onDropdownIconClick = onDropDownClick,
) {
- onPlay()
+ onPlay(event)
}
}
@@ -354,6 +390,10 @@ fun SimilarListens(
}
}
+@Composable
+fun StartingSpacer() {
+ Spacer(modifier = Modifier.height(60.dp)) // 6 + 6 + 48
+}
@Composable
fun NavigationChips(
@@ -361,11 +401,20 @@ fun NavigationChips(
scope: CoroutineScope = rememberCoroutineScope(),
onClick: suspend (Int) -> Unit
){
- Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
+ Row(modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .background(
+ Brush.verticalGradient(
+ listOf(
+ ListenBrainzTheme.colorScheme.background,
+ Color.Transparent
+ )
+ )
+ )
+ ) {
+ Spacer(modifier = Modifier.width(ListenBrainzTheme.paddings.chipsHorizontal/2))
repeat(3){ position ->
- if (position == 0) {
- Spacer(modifier = Modifier.width(ListenBrainzTheme.paddings.chipsHorizontal/2))
- }
ElevatedSuggestionChip(
modifier = Modifier.padding(ListenBrainzTheme.paddings.chipsHorizontal),
colors = SuggestionChipDefaults.elevatedSuggestionChipColors(
diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/listens/ListensScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/listens/ListensScreen.kt
index 8584186a..33b84263 100644
--- a/app/src/main/java/org/listenbrainz/android/ui/screens/listens/ListensScreen.kt
+++ b/app/src/main/java/org/listenbrainz/android/ui/screens/listens/ListensScreen.kt
@@ -1,13 +1,12 @@
package org.listenbrainz.android.ui.screens.listens
-import android.content.Context
-import android.content.Intent
import android.net.Uri
-import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
@@ -31,26 +30,21 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.spotify.android.appremote.api.SpotifyAppRemote
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
import org.listenbrainz.android.R
import org.listenbrainz.android.model.Listen
import org.listenbrainz.android.ui.components.ListenCardSmall
import org.listenbrainz.android.ui.components.LoadingAnimation
import org.listenbrainz.android.ui.screens.profile.UserData
import org.listenbrainz.android.ui.theme.ListenBrainzTheme
-import org.listenbrainz.android.util.Constants
import org.listenbrainz.android.util.Utils.getCoverArtUrl
import org.listenbrainz.android.viewmodel.ListensViewModel
@@ -61,8 +55,6 @@ fun ListensScreen(
spotifyClientId: String = stringResource(id = R.string.spotifyClientId),
scrollRequestState: Boolean,
onScrollToTop: (suspend () -> Unit) -> Unit,
- context: Context = LocalContext.current,
- scope: CoroutineScope = rememberCoroutineScope()
) {
DisposableEffect(Unit) {
viewModel.connect(spotifyClientId = spotifyClientId)
@@ -88,70 +80,21 @@ fun ListensScreen(
}
}
- val youtubeApiKey = stringResource(id = R.string.youtubeApiKey)
-
fun onListenTap(listen: Listen) {
if (listen.trackMetadata.additionalInfo?.spotifyId != null) {
Uri.parse(listen.trackMetadata.additionalInfo.spotifyId).lastPathSegment?.let { trackId ->
- viewModel.playUri("spotify:track:${trackId}")
+ viewModel.playUri(trackId)
}
} else {
// Execute the API request asynchronously
- scope.launch {
- val videoId = viewModel
- .searchYoutubeMusicVideoId(
- context = context,
- trackName = listen.trackMetadata.trackName,
- artist = listen.trackMetadata.artistName,
- apiKey = youtubeApiKey
- )
- when {
- videoId != null -> {
- // Play the track in the YouTube Music app
- val trackUri =
- Uri.parse("https://music.youtube.com/watch?v=$videoId")
- val intent = Intent(Intent.ACTION_VIEW)
- intent.data = trackUri
- intent.setPackage(Constants.YOUTUBE_MUSIC_PACKAGE_NAME)
- val activities =
- context.packageManager.queryIntentActivities(intent, 0)
-
- when {
- activities.isNotEmpty() -> {
- context.startActivity(intent)
- }
-
- else -> {
- // Display an error message
- Toast.makeText(
- context,
- "YouTube Music is not installed to play the track.",
- Toast.LENGTH_SHORT
- ).show()
- }
- }
- }
-
- else -> {
- /*
- // Play track via Amazon Music
- val intent = Intent()
- val query = listen.trackMetadata.trackName + " " + listen.trackMetadata.artistName
- intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- intent.setClassName(
- "com.amazon.mp3",
- "com.amazon.mp3.activity.IntentProxyActivity"
- )
- intent.action = MediaStore.INTENT_ACTION_MEDIA_SEARCH
- intent.putExtra(MediaStore.EXTRA_MEDIA_TITLE, query)
- context.startActivity(intent)
- */
- }
- }
- }
+ viewModel.playFromYoutubeMusic(
+ listen.trackMetadata.trackName,
+ listen.trackMetadata.artistName
+ )
}
}
+ /** Content **/
// Listens list
val listens = viewModel.listensFlow.collectAsState().value
@@ -174,7 +117,11 @@ fun ListensScreen(
HorizontalPager(state = pagerState, pageCount = 2, modifier = Modifier.fillMaxSize()) { page ->
when (page) {
0 -> {
- AnimatedVisibility(visible = viewModel.listeningNow.collectAsState().value != null) {
+ AnimatedVisibility(
+ visible = viewModel.listeningNow.collectAsState().value != null,
+ enter = slideInVertically(),
+ exit = slideOutVertically()
+ ) {
ListeningNowCard(
listeningNow!!,
getCoverArtUrl(
@@ -188,7 +135,11 @@ fun ListensScreen(
}
1 -> {
- AnimatedVisibility(visible = viewModel.playerState?.track?.name != null) {
+ AnimatedVisibility(
+ visible = viewModel.playerState?.track?.name != null,
+ enter = slideInVertically(),
+ exit = slideOutVertically()
+ ) {
ListeningNowOnSpotify(
playerState = viewModel.playerState,
bitmap = viewModel.bitmap
@@ -203,7 +154,7 @@ fun ListensScreen(
ListenCardSmall(
modifier = Modifier.padding(
horizontal = ListenBrainzTheme.paddings.horizontal,
- vertical = ListenBrainzTheme.paddings.listenListVertical
+ vertical = ListenBrainzTheme.paddings.lazyListAdjacent
),
releaseName = listen.trackMetadata.trackName,
artistName = listen.trackMetadata.artistName,
@@ -232,7 +183,7 @@ fun ListensScreen(
}
// FAB
- // FIXME: MOVE ACCESS TO SHARED PREFERENCES TO COROUTINES.
+ // FIXME: MOVE ACCESS OF SHARED PREFERENCES TO COROUTINES.
if(viewModel.appPreferences.isNotificationServiceAllowed) {
AnimatedVisibility(
modifier = Modifier
diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/search/SearchScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/search/SearchScreen.kt
index 940a35c9..e08fe433 100644
--- a/app/src/main/java/org/listenbrainz/android/ui/screens/search/SearchScreen.kt
+++ b/app/src/main/java/org/listenbrainz/android/ui/screens/search/SearchScreen.kt
@@ -63,11 +63,12 @@ import org.listenbrainz.android.viewmodel.SearchViewModel
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SearchScreen(
- searchBarState: SearchBarState,
- viewModel: SearchViewModel = hiltViewModel()
+ isActive: Boolean,
+ viewModel: SearchViewModel = hiltViewModel(),
+ deactivate: () -> Unit
) {
AnimatedVisibility(
- visible = searchBarState.isActive,
+ visible = isActive,
enter = fadeIn(),
exit = fadeOut()
) {
@@ -77,7 +78,7 @@ fun SearchScreen(
SearchScreen(
uiState = uiState,
onDismiss = {
- searchBarState.deactivate()
+ deactivate()
viewModel.clearUi()
},
onQueryChange = { query -> viewModel.updateQueryFlow(query) },
@@ -162,7 +163,7 @@ private fun SearchScreen(
inputFieldColors = SearchBarDefaults.inputFieldColors(
focusedPlaceholderColor = Color.Unspecified,
focusedTextColor = ListenBrainzTheme.colorScheme.text,
- cursorColor = ListenBrainzTheme.colorScheme.lbSignature,
+ cursorColor = ListenBrainzTheme.colorScheme.lbSignatureInverse,
)
),
) {
diff --git a/app/src/main/java/org/listenbrainz/android/ui/theme/Theme.kt b/app/src/main/java/org/listenbrainz/android/ui/theme/Theme.kt
index f25a2fa1..cf96f478 100644
--- a/app/src/main/java/org/listenbrainz/android/ui/theme/Theme.kt
+++ b/app/src/main/java/org/listenbrainz/android/ui/theme/Theme.kt
@@ -178,8 +178,7 @@ data class Paddings(
// New set
val horizontal: Dp = 9.dp,
val vertical: Dp = 8.dp,
- val listenListVertical: Dp = 4.dp,
- val lazyListAdjacent: Dp = 8.dp,
+ val lazyListAdjacent: Dp = 6.dp,
val coverArtAndTextGap: Dp = 8.dp,
val insideCard: Dp = 8.dp,
val chipsHorizontal: Dp = 6.dp
diff --git a/app/src/main/java/org/listenbrainz/android/viewmodel/FeedViewModel.kt b/app/src/main/java/org/listenbrainz/android/viewmodel/FeedViewModel.kt
index ac760c9d..96529e86 100644
--- a/app/src/main/java/org/listenbrainz/android/viewmodel/FeedViewModel.kt
+++ b/app/src/main/java/org/listenbrainz/android/viewmodel/FeedViewModel.kt
@@ -1,5 +1,6 @@
package org.listenbrainz.android.viewmodel
+import android.net.Uri
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.lifecycle.ViewModel
@@ -28,6 +29,7 @@ import org.listenbrainz.android.model.FeedEventVisibilityData
import org.listenbrainz.android.model.ResponseError
import org.listenbrainz.android.repository.feed.FeedRepository
import org.listenbrainz.android.repository.preferences.AppPreferences
+import org.listenbrainz.android.repository.remoteplayer.RemotePlayerRepository
import org.listenbrainz.android.repository.social.SocialRepository
import org.listenbrainz.android.ui.screens.feed.FeedScreenUiState
import org.listenbrainz.android.ui.screens.feed.FeedUiEventData
@@ -36,6 +38,7 @@ import org.listenbrainz.android.ui.screens.feed.FeedUiState
import org.listenbrainz.android.ui.screens.feed.FollowListensPagingSource
import org.listenbrainz.android.ui.screens.feed.MyFeedPagingSource
import org.listenbrainz.android.ui.screens.feed.SimilarListensPagingSource
+import org.listenbrainz.android.util.Log.d
import org.listenbrainz.android.util.Resource
import javax.inject.Inject
@@ -44,6 +47,7 @@ class FeedViewModel @Inject constructor(
private val feedRepository: FeedRepository,
private val socialRepository: SocialRepository,
private val appPreferences: AppPreferences,
+ private val remotePlayerRepository: RemotePlayerRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher
): ViewModel() {
@@ -151,9 +155,7 @@ class FeedViewModel @Inject constructor(
isHiddenMap[id] = value
},
onError = { error ->
- viewModelScope.launch(defaultDispatcher) {
- errorFlow.emit(error)
- }
+ emitError(error)
},
feedRepository = feedRepository,
ioDispatcher = ioDispatcher
@@ -163,9 +165,7 @@ class FeedViewModel @Inject constructor(
FollowListensPagingSource(
username = { appPreferences.username },
onError = { error ->
- viewModelScope.launch(defaultDispatcher) {
- errorFlow.emit(error)
- }
+ emitError(error)
},
feedRepository = feedRepository,
ioDispatcher = ioDispatcher
@@ -175,14 +175,60 @@ class FeedViewModel @Inject constructor(
SimilarListensPagingSource(
username = { appPreferences.username },
onError = { error ->
- viewModelScope.launch(defaultDispatcher) {
- errorFlow.emit(error)
- }
+ emitError(error)
},
feedRepository = feedRepository,
ioDispatcher = ioDispatcher
)
+ fun connectToSpotify() {
+ viewModelScope.launch {
+ remotePlayerRepository.connectToSpotify { error ->
+ emitError(error)
+ }
+ }
+ }
+
+ fun disconnectSpotify(){
+ remotePlayerRepository.disconnectSpotify()
+ }
+
+ fun play(event: FeedEvent) {
+ val spotifyId = event.metadata.trackMetadata?.additionalInfo?.spotifyId
+ if (spotifyId != null){
+ Uri.parse(spotifyId).lastPathSegment?.let { trackId ->
+ remotePlayerRepository.playUri(trackId){
+ playFromYoutubeMusic(event)
+ }
+ }
+ } else {
+ playFromYoutubeMusic(event)
+ }
+ }
+
+ private fun playFromYoutubeMusic(event: FeedEvent) {
+ viewModelScope.launch {
+ if (event.metadata.trackMetadata != null){
+ remotePlayerRepository.apply {
+ val result = playOnYoutube {
+ withContext(ioDispatcher) {
+ searchYoutubeMusicVideoId(
+ event.metadata.trackMetadata.trackName,
+ event.metadata.trackMetadata.artistName
+ )
+ }
+ }
+
+ if (result.status == Resource.Status.SUCCESS){
+ d("Play on youtube music successful")
+ }
+ }
+ } else {
+ // Could not play song.
+ }
+ }
+ }
+
fun hideOrDeleteEvent(event: FeedEvent, eventType: FeedEventType, parentUser: String) {
viewModelScope.launch(defaultDispatcher) {
@@ -285,4 +331,10 @@ class FeedViewModel @Inject constructor(
}
}
+ fun emitError(error: ResponseError?) {
+ viewModelScope.launch(defaultDispatcher) {
+ errorFlow.emit(error)
+ }
+ }
+
}
\ No newline at end of file
diff --git a/app/src/main/java/org/listenbrainz/android/viewmodel/ListensViewModel.kt b/app/src/main/java/org/listenbrainz/android/viewmodel/ListensViewModel.kt
index 75062a40..56884fda 100644
--- a/app/src/main/java/org/listenbrainz/android/viewmodel/ListensViewModel.kt
+++ b/app/src/main/java/org/listenbrainz/android/viewmodel/ListensViewModel.kt
@@ -1,8 +1,6 @@
package org.listenbrainz.android.viewmodel
import android.app.Application
-import android.content.Context
-import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -18,6 +16,7 @@ import com.spotify.protocol.client.Subscription
import com.spotify.protocol.types.PlayerContext
import com.spotify.protocol.types.PlayerState
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,14 +24,15 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import okhttp3.OkHttpClient
+import kotlinx.coroutines.withContext
import org.listenbrainz.android.BuildConfig
+import org.listenbrainz.android.di.IoDispatcher
import org.listenbrainz.android.model.Listen
import org.listenbrainz.android.model.ListenBitmap
import org.listenbrainz.android.repository.listens.ListensRepository
import org.listenbrainz.android.repository.preferences.AppPreferences
+import org.listenbrainz.android.repository.remoteplayer.RemotePlayerRepository
import org.listenbrainz.android.repository.socket.SocketRepository
-import org.listenbrainz.android.service.YouTubeApiService
import org.listenbrainz.android.util.Constants
import org.listenbrainz.android.util.LinkedService
import org.listenbrainz.android.util.Log.d
@@ -40,9 +40,6 @@ import org.listenbrainz.android.util.Log.e
import org.listenbrainz.android.util.Resource.Status.FAILED
import org.listenbrainz.android.util.Resource.Status.LOADING
import org.listenbrainz.android.util.Resource.Status.SUCCESS
-import org.listenbrainz.android.util.Utils.getSHA1
-import retrofit2.Retrofit
-import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Inject
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
@@ -54,19 +51,18 @@ class ListensViewModel @Inject constructor(
val repository: ListensRepository,
val appPreferences: AppPreferences,
private val application: Application,
- private val socketRepository: SocketRepository
+ private val socketRepository: SocketRepository,
+ private val remotePlayerRepository: RemotePlayerRepository,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : AndroidViewModel(application) {
// TODO: remove dependency of this view-model on application
// by moving spotify app remote to a repository.
-
- private val _listensFlow = MutableStateFlow(listOf())
- val listensFlow = _listensFlow.asStateFlow()
private val _isSpotifyLinked = MutableStateFlow(appPreferences.linkedServices.contains(LinkedService.SPOTIFY))
val isSpotifyLinked = _isSpotifyLinked.asStateFlow()
- var isLoading: Boolean by mutableStateOf(true)
- var isPaused=false
+ var isLoading: Boolean by mutableStateOf(true)
+ private var isPaused = false
var playerState: PlayerState? by mutableStateOf(null)
private val _songDuration = MutableStateFlow(0L)
private val _songCurrentPosition = MutableStateFlow(0L)
@@ -81,7 +77,12 @@ class ListensViewModel @Inject constructor(
private val errorCallback = { throwable: Throwable -> logError(throwable) }
private var isResumed = false
-
+
+ // Listens list flow
+ private val _listensFlow = MutableStateFlow(listOf())
+ val listensFlow = _listensFlow.asStateFlow()
+
+ // Listening now flow
private val _listeningNowFlow: MutableStateFlow = MutableStateFlow(null)
val listeningNow = _listeningNowFlow.asStateFlow()
@@ -92,9 +93,12 @@ class ListensViewModel @Inject constructor(
socketRepository
.listen(appPreferences.username!!)
.collect { listen ->
- _listensFlow.getAndUpdate {
- listOf(listen) + it
- }
+ if (listen.listenedAt == null)
+ _listeningNowFlow.value = listen
+ else
+ _listensFlow.getAndUpdate {
+ listOf(listen) + it
+ }
}
}
}
@@ -134,58 +138,22 @@ class ListensViewModel @Inject constructor(
}
}
- suspend fun searchYoutubeMusicVideoId(context: Context, trackName: String, artist: String, apiKey: String): String? {
- val packageName = context.packageName
- val sha1 = getSHA1(context, packageName)
-
- val okHttpClient = OkHttpClient.Builder()
- .addInterceptor { chain ->
- val request = chain.request().newBuilder()
- .addHeader("X-Android-Package", packageName)
- .addHeader("X-Android-Cert", sha1 ?: "")
- .build()
- chain.proceed(request)
- }
- .build()
-
- val retrofit = Retrofit.Builder()
- .baseUrl("https://www.googleapis.com/")
- .addConverterFactory(GsonConverterFactory.create())
- .client(okHttpClient)
- .build()
-
- val service = retrofit.create(YouTubeApiService::class.java)
-
- return try {
- val response = service.searchVideos(
- "snippet",
- "$trackName $artist",
- "video",
- "10",
- apiKey
- )
-
- if (response.isSuccessful) {
- val items = response.body()?.items ?: emptyList()
- if (items.isNotEmpty()) {
- items[0].id.videoId
- } else {
- null
+ fun playFromYoutubeMusic(trackName: String, artist: String) {
+ viewModelScope.launch {
+ remotePlayerRepository.apply {
+ playOnYoutube {
+ withContext(ioDispatcher) {
+ searchYoutubeMusicVideoId(trackName, artist)
+ }
}
- } else {
- Log.e("YouTube API error", response.errorBody()?.string() ?: "")
- null
}
- } catch (e: Exception) {
- Log.e("YouTube API error", "Error occurred while searching for video ID", e)
- null
}
}
private fun updateTrackCoverArt(playerState: PlayerState) {
// Get image from track
assertAppRemoteConnected()?.imagesApi?.getImage(playerState.track.imageUri, com.spotify.protocol.types.Image.Dimension.LARGE)?.setResultCallback { bitmapHere ->
- bitmap =ListenBitmap(
+ bitmap = ListenBitmap(
bitmap=bitmapHere,
id = playerState.track.uri
)
@@ -278,10 +246,11 @@ class ListensViewModel @Inject constructor(
isPaused=true
trackProgress()
}
+
fun trackProgress() {
var state: PlayerState?
assertAppRemoteConnected()?.playerApi?.subscribeToPlayerState()?.setEventCallback { playerState ->
- if(bitmap.id!=playerState.track.uri) {
+ if(bitmap.id != playerState.track.uri) {
updateTrackCoverArt(playerState)
state=playerState
}
diff --git a/app/src/test/java/org/listenbrainz/android/SocialRepositoryTest.kt b/app/src/test/java/org/listenbrainz/android/SocialRepositoryTest.kt
index c8b36729..bc2ebee9 100644
--- a/app/src/test/java/org/listenbrainz/android/SocialRepositoryTest.kt
+++ b/app/src/test/java/org/listenbrainz/android/SocialRepositoryTest.kt
@@ -15,7 +15,6 @@ import org.listenbrainz.android.repository.social.SocialRepositoryImpl
import org.listenbrainz.android.service.SocialService
import org.listenbrainz.android.util.Resource
import org.listenbrainz.sharedtest.testdata.SocialRepositoryTestData.ErrorUtil.alreadyFollowingError
-import org.listenbrainz.sharedtest.testdata.SocialRepositoryTestData.ErrorUtil.authHeaderNotFoundError
import org.listenbrainz.sharedtest.testdata.SocialRepositoryTestData.ErrorUtil.cannotFollowSelfError
import org.listenbrainz.sharedtest.testdata.SocialRepositoryTestData.ErrorUtil.userNotFoundError
import org.listenbrainz.sharedtest.testdata.SocialRepositoryTestData.testFollowersSuccessData
@@ -162,7 +161,7 @@ class SocialRepositoryTest {
}
webServer.start()
val service = RetrofitUtils.createTestService(SocialService::class.java, webServer.url("/"))
- repository = SocialRepositoryImpl(service, testAuthHeader)
+ repository = SocialRepositoryImpl(service)
}
@After
@@ -250,14 +249,6 @@ class SocialRepositoryTest {
assertEquals(null ,result.data?.status)
assertEquals(ResponseError.BAD_REQUEST, result.error)
assertEquals(alreadyFollowingError, result.error?.actualResponse)
-
- // No Auth Header
- result = repository.followUser(testFamiliarUser) // Token is empty.
-
- assertEquals(Resource.Status.FAILED, result.status)
- assertEquals(null ,result.data?.status)
- assertEquals(ResponseError.AUTH_HEADER_NOT_FOUND, result.error)
- assertEquals(authHeaderNotFoundError, result.error?.actualResponse)
}
/* unfollow() tests */
@@ -287,8 +278,6 @@ class SocialRepositoryTest {
assertEquals(Resource.Status.FAILED, result.status)
assertEquals(null ,result.data?.status)
- assertEquals(ResponseError.AUTH_HEADER_NOT_FOUND, result.error)
- assertEquals(authHeaderNotFoundError, result.error?.actualResponse)
}
@Test
diff --git a/sharedTest/src/main/java/org/listenbrainz/sharedtest/mocks/MockAppPreferences.kt b/sharedTest/src/main/java/org/listenbrainz/sharedtest/mocks/MockAppPreferences.kt
index 2193f15c..0a0a4e03 100644
--- a/sharedTest/src/main/java/org/listenbrainz/sharedtest/mocks/MockAppPreferences.kt
+++ b/sharedTest/src/main/java/org/listenbrainz/sharedtest/mocks/MockAppPreferences.kt
@@ -47,7 +47,7 @@ class MockAppPreferences(
override suspend fun getLbAccessToken(): String = testAccessToken
override fun getLbAccessTokenFlow(): Flow = flow {
- TODO("Not yet implemented")
+ emit(testAccessToken)
}
override suspend fun setLbAccessToken(value: String) {
diff --git a/sharedTest/src/main/java/org/listenbrainz/sharedtest/testdata/SocialRepositoryTestData.kt b/sharedTest/src/main/java/org/listenbrainz/sharedtest/testdata/SocialRepositoryTestData.kt
index 66ea4f22..f6b41dcc 100644
--- a/sharedTest/src/main/java/org/listenbrainz/sharedtest/testdata/SocialRepositoryTestData.kt
+++ b/sharedTest/src/main/java/org/listenbrainz/sharedtest/testdata/SocialRepositoryTestData.kt
@@ -15,7 +15,7 @@ object SocialRepositoryTestData {
object ErrorUtil {
const val userNotFoundError = "User Some_User_That_Does_Not_Exist not found"
const val authHeaderNotFoundError = "You need to provide an Authorization header."
- const val alreadyFollowingError = "Jasjeet is already following user JasjeetTest."
+ const val alreadyFollowingError = "Jasjeet is already following user JasjeetTest"
const val cannotFollowSelfError = "Whoops, cannot follow yourself."
}
diff --git a/sharedTest/src/main/java/org/listenbrainz/sharedtest/utils/EntityTestUtils.kt b/sharedTest/src/main/java/org/listenbrainz/sharedtest/utils/EntityTestUtils.kt
index 143e3b47..a69ab7b1 100644
--- a/sharedTest/src/main/java/org/listenbrainz/sharedtest/utils/EntityTestUtils.kt
+++ b/sharedTest/src/main/java/org/listenbrainz/sharedtest/utils/EntityTestUtils.kt
@@ -28,7 +28,7 @@ object EntityTestUtils {
/** Access token of [testUsername]*/
const val testAccessToken = "8OC8as-1VpATqk-M79Kf-cdTw123a"
- const val testAuthHeader = "Bearer $testAccessToken"
+ const val testAuthHeader = "Token $testAccessToken"
/** Main user that is supposed to be logged in.*/
const val testUsername = "Jasjeet"
diff --git a/sharedTest/src/main/java/org/listenbrainz/sharedtest/utils/RetrofitUtils.kt b/sharedTest/src/main/java/org/listenbrainz/sharedtest/utils/RetrofitUtils.kt
index bff332c3..d351eacf 100644
--- a/sharedTest/src/main/java/org/listenbrainz/sharedtest/utils/RetrofitUtils.kt
+++ b/sharedTest/src/main/java/org/listenbrainz/sharedtest/utils/RetrofitUtils.kt
@@ -1,12 +1,21 @@
package org.listenbrainz.sharedtest.utils
import okhttp3.HttpUrl
+import okhttp3.OkHttpClient
+import org.listenbrainz.android.util.HeaderInterceptor
+import org.listenbrainz.sharedtest.mocks.MockAppPreferences
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object RetrofitUtils {
fun createTestService(service: Class, baseUrl: HttpUrl): S {
val retrofit = Retrofit.Builder()
+ .client(
+ OkHttpClient()
+ .newBuilder()
+ .addInterceptor(HeaderInterceptor(MockAppPreferences()))
+ .build()
+ )
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(baseUrl)
.build()