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()