diff --git a/app/build.gradle b/app/build.gradle index befdd77..2d4150a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,8 +47,8 @@ android { applicationId "io.musicorum.mobile" minSdk 28 targetSdk 34 - versionCode 67 - versionName "1.22-release" + versionCode 68 + versionName "1.23-release" //compileSdkPreview = "UpsideDownCake" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -116,7 +116,7 @@ play { dependencies { implementation 'org.reflections:reflections:0.10.2' implementation 'com.github.skydoves:balloon-compose:1.5.2' - implementation 'androidx.work:work-runtime-ktx:2.7.1' + implementation 'androidx.work:work-runtime-ktx:2.9.0-rc01' implementation "androidx.paging:paging-compose:3.3.0-alpha02" implementation 'com.github.crowdin.mobile-sdk-android:sdk:1.5.7' implementation platform('androidx.compose:compose-bom:2022.11.00') @@ -127,18 +127,18 @@ dependencies { implementation 'io.sentry:sentry-android:6.27.0' implementation 'io.sentry:sentry-compose-android:6.27.0' - def room_version = "2.5.2" + def room_version = "2.6.0" implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version" ksp "androidx.room:room-compiler:$room_version" - implementation "com.google.dagger:hilt-android:2.44.2" + implementation "com.google.dagger:hilt-android:2.47" implementation 'androidx.core:core-ktx:1.10.0' kapt "com.google.dagger:hilt-compiler:2.48" - implementation 'androidx.hilt:hilt-navigation-compose:1.0.0' + implementation 'androidx.hilt:hilt-navigation-compose:1.1.0' - implementation 'com.google.firebase:firebase-messaging-ktx:23.2.1' + implementation 'com.google.firebase:firebase-messaging-ktx:23.3.1' implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1" implementation 'app.rive:rive-android:4.0.0' implementation "androidx.startup:startup-runtime:1.1.1" @@ -147,7 +147,7 @@ dependencies { implementation 'com.google.firebase:firebase-analytics-ktx' implementation 'com.google.firebase:firebase-config-ktx' implementation platform('com.google.firebase:firebase-bom:31.0.2') - implementation 'androidx.browser:browser:1.6.0' + implementation 'androidx.browser:browser:1.7.0' implementation "io.ktor:ktor-client-core:$ktor_version" implementation "io.ktor:ktor-client-android:$ktor_version" implementation "io.ktor:ktor-client-cio:$ktor_version" @@ -155,7 +155,7 @@ dependencies { implementation "io.ktor:ktor-client-content-negotiation:$ktor_version" implementation "io.ktor:ktor-serialization-kotlinx-xml:$ktor_version" implementation 'com.google.accompanist:accompanist-systemuicontroller:0.26.5-rc' - implementation 'androidx.navigation:navigation-compose:2.7.1' + implementation 'androidx.navigation:navigation-compose:2.7.5' implementation "androidx.compose.material:material-icons-extended" implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' @@ -164,7 +164,7 @@ dependencies { implementation 'androidx.palette:palette-ktx:1.0.0' implementation 'com.github.ajalt.colormath:colormath:3.2.1' implementation 'com.github.ajalt.colormath.extensions:colormath-ext-jetpack-compose:3.2.1' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.2' implementation 'androidx.core:core-ktx:1.10.0' @@ -172,7 +172,7 @@ dependencies { implementation 'androidx.activity:activity-compose:1.8.0-rc01' implementation "androidx.compose.ui:ui" implementation "androidx.compose.ui:ui-tooling-preview" - implementation 'androidx.compose.material3:material3:1.2.0-alpha09' + implementation 'androidx.compose.material3:material3:1.2.0-alpha11' implementation 'com.google.accompanist:accompanist-placeholder-material:0.26.5-rc' implementation 'io.coil-kt:coil-compose:2.4.0' implementation 'androidx.datastore:datastore-preferences:1.0.0' diff --git a/app/src/main/java/io/musicorum/mobile/MainActivity.kt b/app/src/main/java/io/musicorum/mobile/MainActivity.kt index 11ee40a..ca71913 100644 --- a/app/src/main/java/io/musicorum/mobile/MainActivity.kt +++ b/app/src/main/java/io/musicorum/mobile/MainActivity.kt @@ -21,7 +21,6 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -40,7 +39,6 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -75,7 +73,6 @@ import io.musicorum.mobile.utils.CrowdinUtils import io.musicorum.mobile.utils.LocalSnackbar import io.musicorum.mobile.utils.LocalSnackbarContext import io.musicorum.mobile.utils.MessagingService -import io.musicorum.mobile.viewmodels.MostListenedViewModel import io.musicorum.mobile.views.Account import io.musicorum.mobile.views.Collage import io.musicorum.mobile.views.Discover @@ -93,6 +90,7 @@ import io.musicorum.mobile.views.individual.Track import io.musicorum.mobile.views.individual.User import io.musicorum.mobile.views.login.loginGraph import io.musicorum.mobile.views.mostListened.MostListened +import io.musicorum.mobile.views.settings.PendingScrobbles import io.musicorum.mobile.views.settings.ScrobbleSettings import io.musicorum.mobile.views.settings.Settings import io.sentry.android.core.SentryAndroid @@ -129,7 +127,6 @@ class MainActivity : ComponentActivity() { super.attachBaseContext(Crowdin.wrapContext(newBase)) } - @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) @@ -210,7 +207,6 @@ class MainActivity : ComponentActivity() { val useDarkIcons = !isSystemInDarkTheme() navController = rememberNavController().withSentryObservableEffect() - val mostListenedViewModel: MostListenedViewModel = viewModel() val ctx = LocalContext.current val snackHostState = remember { SnackbarHostState() } val systemUiController = rememberSystemUiController() @@ -349,7 +345,7 @@ class MainActivity : ComponentActivity() { } composable("mostListened") { - MostListened(viewModel = mostListenedViewModel) + MostListened() } composable( @@ -411,6 +407,7 @@ class MainActivity : ComponentActivity() { composable("settings") { Settings() } composable("settings/scrobble") { ScrobbleSettings() } + composable("settings/pendingScrobbles") { PendingScrobbles() } composable( "tag/{tagName}", diff --git a/app/src/main/java/io/musicorum/mobile/components/MusicorumTopBar.kt b/app/src/main/java/io/musicorum/mobile/components/MusicorumTopBar.kt index 145cb32..4b9257b 100644 --- a/app/src/main/java/io/musicorum/mobile/components/MusicorumTopBar.kt +++ b/app/src/main/java/io/musicorum/mobile/components/MusicorumTopBar.kt @@ -1,10 +1,15 @@ package io.musicorum.mobile.components -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.RowScope import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack -import androidx.compose.material3.* +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -46,7 +51,7 @@ fun MusicorumTopBar( analytics.logEvent("topbar_back_pressed", null) }, ) { - Icon(Icons.Rounded.ArrowBack, contentDescription = "") + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "") } }, scrollBehavior = scrollBehavior, diff --git a/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt b/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt index 4417831..0f52900 100644 --- a/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt +++ b/app/src/main/java/io/musicorum/mobile/components/TrackSheet.kt @@ -20,10 +20,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FavoriteBorder -import androidx.compose.material.icons.rounded.OpenInNew import androidx.compose.material.icons.rounded.Share import androidx.compose.material.icons.rounded.Star import androidx.compose.material3.ExperimentalMaterial3Api @@ -217,7 +217,7 @@ fun TrackSheet( ctx.startActivity(intent) }, headlineContent = { Text(text = "Open on Last.fm") }, - leadingContent = { Icon(Icons.Rounded.OpenInNew, null) }, + leadingContent = { Icon(Icons.AutoMirrored.Rounded.OpenInNew, null) }, colors = listColors ) ListItem( diff --git a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/ArtistEndpoint.kt b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/ArtistEndpoint.kt index 74d4548..6d9d79f 100644 --- a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/ArtistEndpoint.kt +++ b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/ArtistEndpoint.kt @@ -17,7 +17,7 @@ object ArtistEndpoint { parameter("method", "artist.getInfo") parameter("name", artist) parameter("artist", artist) - parameter("usernameArg", username) + parameter("username", username) } return if (res.status.isSuccess()) { return res.body() diff --git a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/TrackEndpoint.kt b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/TrackEndpoint.kt index d9a1fb1..900c56b 100644 --- a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/TrackEndpoint.kt +++ b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/TrackEndpoint.kt @@ -27,7 +27,7 @@ object TrackEndpoint { val autoCorrectValue = if (autoCorrect == true) 1 else 0 parameter("track", trackName) parameter("method", "track.getInfo") - parameter("usernameArg", username) + parameter("username", username) parameter("artist", artist) parameter("autocorrect", autoCorrectValue) } diff --git a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/UserEndpoint.kt b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/UserEndpoint.kt index ee4d07e..400a3fd 100644 --- a/app/src/main/java/io/musicorum/mobile/ktor/endpoints/UserEndpoint.kt +++ b/app/src/main/java/io/musicorum/mobile/ktor/endpoints/UserEndpoint.kt @@ -1,9 +1,11 @@ package io.musicorum.mobile.ktor.endpoints import io.ktor.client.call.body +import io.ktor.client.plugins.ClientRequestException import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post +import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode import io.ktor.http.isSuccess import io.musicorum.mobile.ktor.KtorConfiguration @@ -87,7 +89,7 @@ object UserEndpoint { } - suspend fun getFriends(user: String, limit: Int?): FriendsResponse? { + suspend fun getFriends(user: String, limit: Int?): FriendsResponse? { val result = kotlin.runCatching { val res = KtorConfiguration.lastFmClient.get { parameter("method", "user.getFriends") @@ -104,17 +106,17 @@ object UserEndpoint { } suspend fun getTopTracks(user: String, period: FetchPeriod?, limit: Int?): TopTracks? { - val res = KtorConfiguration.lastFmClient.get { - parameter("method", "user.getTopTracks") - parameter("user", user) - parameter("period", period?.value) - parameter("limit", limit) - } - return if (res.status.isSuccess()) { - res.body() - } else { - null - } + val res = KtorConfiguration.lastFmClient.get { + parameter("method", "user.getTopTracks") + parameter("user", user) + parameter("period", period?.value) + parameter("limit", limit) + } + return if (res.status.isSuccess()) { + res.body() + } else { + null + } } suspend fun getTopAlbums(user: String, period: FetchPeriod?, limit: Int?): TopAlbumsResponse? { @@ -169,6 +171,10 @@ object UserEndpoint { parameter("method", "track.scrobble") parameter("timestamp", timestamp) } + + if (!req.status.isSuccess()) { + throw ClientRequestException(req, req.bodyAsText()) + } return req.status } } \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/router/Routes.kt b/app/src/main/java/io/musicorum/mobile/router/Routes.kt index f7c7726..f604944 100644 --- a/app/src/main/java/io/musicorum/mobile/router/Routes.kt +++ b/app/src/main/java/io/musicorum/mobile/router/Routes.kt @@ -25,6 +25,7 @@ object Routes { const val settings = "settings" const val login = "login" const val scrobbleSettings = "settings/scrobble" + const val pendingScrobbles = "settings/pendingScrobbles" fun albumTracklist(data: String) = "album/$data" const val scrobbling = "scrobbling" const val profile = "profile" diff --git a/app/src/main/java/io/musicorum/mobile/services/NotificationListener.kt b/app/src/main/java/io/musicorum/mobile/services/NotificationListener.kt index 01b774a..de53249 100644 --- a/app/src/main/java/io/musicorum/mobile/services/NotificationListener.kt +++ b/app/src/main/java/io/musicorum/mobile/services/NotificationListener.kt @@ -17,60 +17,41 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.work.BackoffPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf import com.google.firebase.analytics.FirebaseAnalytics -import io.ktor.http.isSuccess import io.musicorum.mobile.database.PendingScrobblesDb import io.musicorum.mobile.ktor.endpoints.UserEndpoint -import io.musicorum.mobile.models.PendingScrobble import io.musicorum.mobile.repositories.PendingScrobblesRepository import io.musicorum.mobile.scrobblePrefs import io.musicorum.mobile.userData +import io.musicorum.mobile.workers.ScrobbleWorker +import io.musicorum.mobile.workers.SyncScrobblesWorker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.net.UnknownHostException -import java.util.Date +import java.util.concurrent.TimeUnit class NotificationListener : NotificationListenerService() { private val tag = "NotificationListener" - private var job: Job? = null private val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { super.onAvailable(network) Log.d(tag, "internet connection available. syncing offline scrobbles") - CoroutineScope(Dispatchers.IO).launch { - val sessionKey = applicationContext.userData.data.map { p -> - p[stringPreferencesKey("session_key")] - }.first() ?: return@launch - - if (!this@NotificationListener::offlineScrobblesRepo::isInitialized.get()) { - Log.w(tag, "Couldn't init offline scrobbles repo, aborting.") - return@launch - } + val work = OneTimeWorkRequestBuilder() + .setBackoffCriteria(BackoffPolicy.LINEAR, 1L, TimeUnit.MINUTES) + .addTag("SYNC_SCROBBLES") + .build() - val scrobbles = offlineScrobblesRepo.getAllScrobblesStream().first() - if (scrobbles.isEmpty()) { - Log.d(tag, "no scrobbles to sync") - } else { - for (scrobble in scrobbles) { - val res = UserEndpoint.scrobble( - track = scrobble.trackName, - artist = scrobble.artistName, - sessionKey = sessionKey, - timestamp = scrobble.timestamp / 1000, - album = scrobble.album - ) - if (res.isSuccess()) { - offlineScrobblesRepo.deleteScrobble(scrobble) - } - } - } - } + WorkManager.getInstance(applicationContext) + .enqueueUniqueWork("SYNC_SCROBBLES", ExistingWorkPolicy.KEEP, work) } override fun onLost(network: Network) { @@ -151,7 +132,6 @@ class NotificationListener : NotificationListenerService() { }.first() } - val timestamp = Date() val analytics = FirebaseAnalytics.getInstance(applicationContext) if (artist == null || track == null) { @@ -179,53 +159,21 @@ class NotificationListener : NotificationListenerService() { } } if (timeToScrobble < 0) return + val workManager = WorkManager.getInstance(applicationContext) - job = if (isPlayerPaused) { - Log.d(tag, "player has been paused") - job?.cancel() - if (job?.isCancelled == true) Log.d(tag, "job has been cancelled") - null + if (isPlayerPaused) { + workManager.cancelAllWorkByTag("SCROBBLE") } else { - job?.cancel() - Log.d(tag, "lauching new job...") - CoroutineScope(Dispatchers.IO).launch { - Log.d("listener job", "this job will wait ${timeToScrobble / 1000} seconds.") - delay(timeToScrobble.toLong()) - Log.d("listener job", "time reached - scrobbling.") - try { - val reqStatus = UserEndpoint.scrobble( - track = track, - artist = artist, - album = album, - albumArtist = albumArtist, - sessionKey = sessionKey!!, - timestamp = timestamp.time / 1000 - ) - - val success = reqStatus.isSuccess() - Log.d(tag, "is scrobble success? $success") - if (success) { - analytics.logEvent("device_scrobble_success", null) - } - } catch (e: Exception) { - if (e is UnknownHostException) { - val offlineScrobble = PendingScrobble( - artistName = artist, - trackName = track, - timestamp = timestamp.time, - album = album - ) - offlineScrobblesRepo.insertScrobble(offlineScrobble) - Log.d(tag, "scrobble has been saved offline") - } else { - val bundle = Bundle() - bundle.putString("reason", "exception") - bundle.putString("exception", e.message) - analytics.logEvent("device_scrobble_failed", bundle) - } - } - } + val data = workDataOf( + "TRACK_NAME" to track, + "TRACK_ARTIST" to artist + ) + val scrobbleWork = OneTimeWorkRequestBuilder() + .setInitialDelay(timeToScrobble.toLong(), TimeUnit.MILLISECONDS) + .setInputData(data) + .addTag("SCROBBLE") + .build() + workManager.enqueueUniqueWork("SCROBBLE", ExistingWorkPolicy.REPLACE, scrobbleWork) } - Log.d(tag, "----------") } } diff --git a/app/src/main/java/io/musicorum/mobile/viewmodels/MostListenedViewModel.kt b/app/src/main/java/io/musicorum/mobile/viewmodels/MostListenedViewModel.kt index 96f8ace..20d5ce4 100644 --- a/app/src/main/java/io/musicorum/mobile/viewmodels/MostListenedViewModel.kt +++ b/app/src/main/java/io/musicorum/mobile/viewmodels/MostListenedViewModel.kt @@ -21,17 +21,20 @@ class MostListenedViewModel(application: Application) : AndroidViewModel(applica fun fetchMostListened(period: FetchPeriod?, limit: Int?) { job = viewModelScope.launch { val localUser = LocalUserRepository(ctx).getUser() - val res = UserEndpoint.getTopTracks(localUser.username, period, limit) - if (res == null) { - error.value = true - return@launch + try { + val res = UserEndpoint.getTopTracks(localUser.username, period, limit) + if (res == null) { + error.value = true + return@launch + } + val musicorumTrRes = MusicorumTrackEndpoint.fetchTracks(res.topTracks.tracks) + musicorumTrRes.forEachIndexed { i, tr -> + val url = tr?.resources?.getOrNull(0)?.bestImageUrl + res.topTracks.tracks[i].bestImageUrl = url ?: "" + } + mosListenedTracks.value = res.topTracks.tracks + } catch (_: Exception) { } - val musicorumTrRes = MusicorumTrackEndpoint.fetchTracks(res.topTracks.tracks) - musicorumTrRes.forEachIndexed { i, tr -> - val url = tr?.resources?.getOrNull(0)?.bestImageUrl - res.topTracks.tracks[i].bestImageUrl = url ?: "" - } - mosListenedTracks.value = res.topTracks.tracks } } diff --git a/app/src/main/java/io/musicorum/mobile/viewmodels/PendingScrobblesViewModel.kt b/app/src/main/java/io/musicorum/mobile/viewmodels/PendingScrobblesViewModel.kt new file mode 100644 index 0000000..429ec4c --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/viewmodels/PendingScrobblesViewModel.kt @@ -0,0 +1,33 @@ +package io.musicorum.mobile.viewmodels + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import androidx.work.WorkManager +import io.musicorum.mobile.database.PendingScrobblesDb +import io.musicorum.mobile.models.PendingScrobble +import io.musicorum.mobile.repositories.PendingScrobblesRepository +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class PendingScrobblesViewModel(application: Application) : AndroidViewModel(application) { + val lastSyncStatus = MutableLiveData("") + val pendingScrobbles = MutableLiveData>(emptyList()) + + init { + val work = WorkManager.getInstance(application) + .getWorkInfosByTag("SYNC_SCROBBLES") + .get() + lastSyncStatus.value = work.getOrNull(0)?.state?.name + Log.d("PendingScrobbles", "Worker will run at ${work.getOrNull(0)?.nextScheduleTimeMillis}") + + val dao = PendingScrobblesDb.getDatabase(application).pendingScrobblesDao() + val repo = PendingScrobblesRepository(dao) + viewModelScope.launch { + val list = repo.getAllScrobblesStream().first() + pendingScrobbles.value = list + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/views/Discover.kt b/app/src/main/java/io/musicorum/mobile/views/Discover.kt index 1d66ee2..c8a7ed6 100644 --- a/app/src/main/java/io/musicorum/mobile/views/Discover.kt +++ b/app/src/main/java/io/musicorum/mobile/views/Discover.kt @@ -25,8 +25,11 @@ import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import io.musicorum.mobile.R import io.musicorum.mobile.components.AlbumListItem import io.musicorum.mobile.components.ArtistListItem import io.musicorum.mobile.components.CenteredLoadingSpinner @@ -51,14 +54,15 @@ fun Discover(viewModel: DiscoverVm = viewModel()) { val busy = viewModel.busy.observeAsState(false).value - Column(modifier = Modifier - .padding(vertical = 20.dp) - .fillMaxSize() - .background(KindaBlack) - .verticalScroll(rememberScrollState()) + Column( + modifier = Modifier + .padding(vertical = 20.dp) + .fillMaxSize() + .background(KindaBlack) + .verticalScroll(rememberScrollState()) ) { Text( - "Discover", + stringResource(R.string.discover), style = Typography.displaySmall, modifier = Modifier.padding(start = 20.dp) ) @@ -76,7 +80,7 @@ fun Discover(viewModel: DiscoverVm = viewModel()) { modifier = Modifier .padding(top = 10.dp) .align(CenterHorizontally), - placeholder = { Text("Search on Last.fm...") }, + placeholder = { Text(stringResource(R.string.search_on_last_fm)) }, leadingIcon = { Icon(Icons.Rounded.Search, null) } ) {} @@ -87,27 +91,39 @@ fun Discover(viewModel: DiscoverVm = viewModel()) { return } - Header(title = "Tracks", results = tracks.size, icon = Icons.Rounded.Audiotrack) + Header( + title = stringResource(R.string.tracks), + results = tracks.size, + icon = Icons.Rounded.Audiotrack + ) if (tracks.isEmpty()) { - Text("No results") + Text(stringResource(R.string.no_results)) } else { tracks.take(4).forEach { TrackListItem(track = it) } } - Header(title = "Albums", results = albums.size, icon = Icons.Outlined.Album) + Header( + title = stringResource(id = R.string.albums), + results = albums.size, + icon = Icons.Outlined.Album + ) if (albums.isEmpty()) { - Text("No results") + Text(stringResource(R.string.no_results)) } else { albums.take(4).forEach { AlbumListItem(it) } } - Header(title = "Artists", results = artists.size, icon = Icons.Rounded.Star) + Header( + title = stringResource(id = R.string.artists), + results = artists.size, + icon = Icons.Rounded.Star + ) if (artists.isEmpty()) { - Text("No results") + Text(stringResource(R.string.no_results)) } else { artists.take(4).forEach { ArtistListItem(artist = it) @@ -122,7 +138,11 @@ private fun Header(title: String, results: Int, icon: ImageVector) { headlineContent = { Text(title, style = Typography.headlineSmall) }, supportingContent = { Text( - text = "$results results", + text = pluralStringResource( + id = R.plurals.search_result_quantity, + count = results, + results + ), style = Typography.bodyMedium, color = ContentSecondary ) diff --git a/app/src/main/java/io/musicorum/mobile/views/Home.kt b/app/src/main/java/io/musicorum/mobile/views/Home.kt index bd35136..61cd7c5 100644 --- a/app/src/main/java/io/musicorum/mobile/views/Home.kt +++ b/app/src/main/java/io/musicorum/mobile/views/Home.kt @@ -274,8 +274,6 @@ fun Home(vm: HomeViewModel = hiltViewModel()) { if (isOffline.value) { Text(text = stringResource(R.string.youre_offline)) } else { - - if (friendsActivity == null && friends == null) { if (errored == true) { Text( diff --git a/app/src/main/java/io/musicorum/mobile/views/RecentScrobbles.kt b/app/src/main/java/io/musicorum/mobile/views/RecentScrobbles.kt index 5af5ed9..89562c5 100644 --- a/app/src/main/java/io/musicorum/mobile/views/RecentScrobbles.kt +++ b/app/src/main/java/io/musicorum/mobile/views/RecentScrobbles.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -51,7 +51,7 @@ fun RecentScrobbles( colors = appBarColors, navigationIcon = { IconButton(onClick = { nav.popBackStack() }) { - Icon(Icons.Rounded.ArrowBack, null) + Icon(Icons.AutoMirrored.Rounded.ArrowBack, null) } } ) diff --git a/app/src/main/java/io/musicorum/mobile/views/Scrobbling.kt b/app/src/main/java/io/musicorum/mobile/views/Scrobbling.kt index 0b8c062..acd107a 100644 --- a/app/src/main/java/io/musicorum/mobile/views/Scrobbling.kt +++ b/app/src/main/java/io/musicorum/mobile/views/Scrobbling.kt @@ -224,7 +224,10 @@ fun NowPlayingCard(track: Track?, fraction: Float, vm: ScrobblingViewModel) { modifier = Modifier.size(12.dp) ) Spacer(modifier = Modifier.width(5.dp)) - Text(text = "NOW PLAYING", style = LabelMedium2.copy(color = Color.White)) + Text( + text = stringResource(R.string.now_playing), + style = LabelMedium2.copy(color = Color.White) + ) } } } diff --git a/app/src/main/java/io/musicorum/mobile/views/individual/Album.kt b/app/src/main/java/io/musicorum/mobile/views/individual/Album.kt index 16b94e2..5e46945 100644 --- a/app/src/main/java/io/musicorum/mobile/views/individual/Album.kt +++ b/app/src/main/java/io/musicorum/mobile/views/individual/Album.kt @@ -58,7 +58,7 @@ fun Album( .fillMaxSize() .background(KindaBlack) ) { - Text(text = "Something went wrong") + Text(text = stringResource(R.string.something_went_wrong)) } } else { val partialAlbum = Json.decodeFromString(albumData) @@ -69,10 +69,11 @@ fun Album( val palette: MutableState = remember { mutableStateOf(null) } val errored = albumViewModel.errored.observeAsState().value val localSnack = LocalSnackbar.current + val failedText = stringResource(R.string.failed_to_fetch_album) LaunchedEffect(key1 = errored) { if (errored == true) { - localSnack.showSnackbar("Failed to fetch album") + localSnack.showSnackbar(failedText) } } diff --git a/app/src/main/java/io/musicorum/mobile/views/individual/AlbumTracklist.kt b/app/src/main/java/io/musicorum/mobile/views/individual/AlbumTracklist.kt index 6ad1cee..4545d1e 100644 --- a/app/src/main/java/io/musicorum/mobile/views/individual/AlbumTracklist.kt +++ b/app/src/main/java/io/musicorum/mobile/views/individual/AlbumTracklist.kt @@ -10,7 +10,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel +import io.musicorum.mobile.R import io.musicorum.mobile.components.AlbumTrack import io.musicorum.mobile.components.CenteredLoadingSpinner import io.musicorum.mobile.components.MusicorumTopBar @@ -30,7 +32,7 @@ fun AlbumTracklist(partialAlbum: PartialAlbum, model: AlbumViewModel = viewModel } else { Scaffold(topBar = { MusicorumTopBar( - text = "Album Tracks", + text = stringResource(R.string.album_tracks), scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), fadeable = false ) {} diff --git a/app/src/main/java/io/musicorum/mobile/views/individual/Artist.kt b/app/src/main/java/io/musicorum/mobile/views/individual/Artist.kt index 7c603f9..fec042d 100644 --- a/app/src/main/java/io/musicorum/mobile/views/individual/Artist.kt +++ b/app/src/main/java/io/musicorum/mobile/views/individual/Artist.kt @@ -123,7 +123,7 @@ fun Artist(artistName: String, artistViewModel: ArtistViewModel = viewModel()) { } HorizontalDivider(modifier = Modifier.padding(vertical = 18.dp)) - Section(title = "Top Tracks") + Section(title = stringResource(id = R.string.top_tracks)) topTracks?.let { TrackListItem(track = it.getOrNull(0), favoriteIcon = false) TrackListItem(track = it.getOrNull(1), favoriteIcon = false) @@ -133,12 +133,12 @@ fun Artist(artistName: String, artistViewModel: ArtistViewModel = viewModel()) { Spacer(modifier = Modifier.height(20.dp)) - Section(title = "Top Albums") + Section(title = stringResource(id = R.string.top_albums)) topAlbums?.let { TopAlbumsRow(albums = it) } Spacer(modifier = Modifier.height(20.dp)) - Section(title = "Similar to $artistName") + Section(title = stringResource(R.string.similar_to, artistName)) artist.similar?.let { ArtistRow(artists = it.artist) diff --git a/app/src/main/java/io/musicorum/mobile/views/individual/User.kt b/app/src/main/java/io/musicorum/mobile/views/individual/User.kt index 7aabd72..e82fb6e 100644 --- a/app/src/main/java/io/musicorum/mobile/views/individual/User.kt +++ b/app/src/main/java/io/musicorum/mobile/views/individual/User.kt @@ -93,7 +93,10 @@ fun User( ) } Text( - text = "Scrobbling since ${user?.user?.registered?.asParsedDate}", + text = stringResource( + R.string.scrobbling_since, + user?.user?.registered?.asParsedDate ?: "" + ), style = Typography.bodyLarge, color = ContentSecondary ) diff --git a/app/src/main/java/io/musicorum/mobile/views/mostListened/MostListened.kt b/app/src/main/java/io/musicorum/mobile/views/mostListened/MostListened.kt index 252eca6..c6d61c8 100644 --- a/app/src/main/java/io/musicorum/mobile/views/mostListened/MostListened.kt +++ b/app/src/main/java/io/musicorum/mobile/views/mostListened/MostListened.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.lifecycle.viewmodel.compose.viewModel import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.ktx.logEvent import io.musicorum.mobile.LocalAnalytics @@ -37,7 +38,7 @@ import io.musicorum.mobile.viewmodels.MostListenedViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MostListened(viewModel: MostListenedViewModel) { +fun MostListened(viewModel: MostListenedViewModel = viewModel()) { val analytics = LocalAnalytics.current!! LaunchedEffect(Unit) { analytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) { diff --git a/app/src/main/java/io/musicorum/mobile/views/settings/PendingScrobbles.kt b/app/src/main/java/io/musicorum/mobile/views/settings/PendingScrobbles.kt new file mode 100644 index 0000000..eb525cf --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/views/settings/PendingScrobbles.kt @@ -0,0 +1,86 @@ +package io.musicorum.mobile.views.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import io.musicorum.mobile.LocalNavigation +import io.musicorum.mobile.R +import io.musicorum.mobile.components.TrackListItem +import io.musicorum.mobile.ui.theme.ContentSecondary +import io.musicorum.mobile.ui.theme.Typography +import io.musicorum.mobile.viewmodels.PendingScrobblesViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PendingScrobbles(viewModel: PendingScrobblesViewModel = viewModel()) { + val lastState by viewModel.lastSyncStatus.observeAsState("") + val scrobbles by viewModel.pendingScrobbles.observeAsState(emptyList()) + val nav = LocalNavigation.current + + Scaffold( + topBar = { + MediumTopAppBar( + title = { Text(stringResource(R.string.pending_scrobbles)) }, + navigationIcon = { + IconButton(onClick = { nav?.popBackStack() }) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, null) + } + }) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + ) { + Text( + text = stringResource( + R.string.last_sync_status, + lastState.lowercase().replaceFirstChar { it.uppercase() }), + style = Typography.labelMedium, + color = ContentSecondary, + modifier = Modifier.padding(horizontal = 20.dp) + ) + + if (scrobbles.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = stringResource(R.string.pending_scrobbles_empty), + style = Typography.titleLarge, + color = ContentSecondary, + + ) + } + } else { + LazyColumn { + items(scrobbles) { + TrackListItem(track = it.toTrack(), favoriteIcon = false) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/views/settings/ScrobbleSettings.kt b/app/src/main/java/io/musicorum/mobile/views/settings/ScrobbleSettings.kt index d24b788..42dd79b 100644 --- a/app/src/main/java/io/musicorum/mobile/views/settings/ScrobbleSettings.kt +++ b/app/src/main/java/io/musicorum/mobile/views/settings/ScrobbleSettings.kt @@ -113,7 +113,7 @@ fun ScrobbleSettings(vm: ScrobbleSettingsVm = viewModel()) { Scaffold(topBar = { MusicorumTopBar( - text = "Scrobble Settings", + text = stringResource(R.string.scrobble_settings), scrollBehavior = appBarBehavior, fadeable = false ) {} diff --git a/app/src/main/java/io/musicorum/mobile/views/settings/Settings.kt b/app/src/main/java/io/musicorum/mobile/views/settings/Settings.kt index 49fa209..20140d8 100644 --- a/app/src/main/java/io/musicorum/mobile/views/settings/Settings.kt +++ b/app/src/main/java/io/musicorum/mobile/views/settings/Settings.kt @@ -17,9 +17,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.rounded.ChevronRight -import androidx.compose.material.icons.rounded.Logout import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -131,7 +131,7 @@ fun Settings(viewModel: SettingsVm = viewModel()) { } } }) { - Icon(Icons.Rounded.Logout, null, tint = MostlyRed) + Icon(Icons.AutoMirrored.Rounded.Logout, null, tint = MostlyRed) } } } @@ -177,6 +177,14 @@ fun Settings(viewModel: SettingsVm = viewModel()) { } ) + ListItem( + headlineContent = { Text("Pending scrobbles") }, + trailingContent = { Icon(Icons.Rounded.ChevronRight, null) }, + modifier = Modifier.clickable { + nav?.navigate(Routes.pendingScrobbles) + } + ) + Spacer(Modifier.height(20.dp)) SectionTitle(sectionName = stringResource(R.string.about)) ListItem( @@ -284,7 +292,7 @@ private fun TopAppBar() { title = { Text(stringResource(id = R.string.settings)) }, navigationIcon = { IconButton(onClick = { nav?.popBackStack() }) { - Icon(Icons.Rounded.ArrowBack, null) + Icon(Icons.AutoMirrored.Rounded.ArrowBack, null) } } ) diff --git a/app/src/main/java/io/musicorum/mobile/workers/ScrobbleWorker.kt b/app/src/main/java/io/musicorum/mobile/workers/ScrobbleWorker.kt new file mode 100644 index 0000000..7a994c8 --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/workers/ScrobbleWorker.kt @@ -0,0 +1,58 @@ +package io.musicorum.mobile.workers + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import io.ktor.http.HttpStatusCode +import io.musicorum.mobile.database.PendingScrobblesDb +import io.musicorum.mobile.datastore.UserData +import io.musicorum.mobile.ktor.endpoints.UserEndpoint +import io.musicorum.mobile.models.PendingScrobble +import io.musicorum.mobile.repositories.PendingScrobblesRepository +import io.musicorum.mobile.userData +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.util.Date + +class ScrobbleWorker(val ctx: Context, workerParameters: WorkerParameters) : + CoroutineWorker(ctx, workerParameters) { + override suspend fun doWork(): Result { + val trackName = inputData.getString("TRACK_NAME") + val trackArtist = inputData.getString("TRACK_ARTIST") + + if (trackName == null || trackArtist == null) { + return Result.failure() + } + + val sessionKey = ctx.userData.data.map { + it[UserData.SESSION_KEY] + }.first() ?: return Result.failure() + + val timestamp = Date().time + + val res = UserEndpoint.scrobble( + track = trackName, + artist = trackArtist, + sessionKey = sessionKey, + timestamp = Date().time / 1000 + ) + + return if (res == HttpStatusCode.OK) { + Log.d("ScrobbleWorker", "scrobble succeeded") + Result.success() + } else { + val pendingScrobble = PendingScrobble( + trackName = trackName, + artistName = trackArtist, + timestamp = timestamp, + album = null + ) + val pendingDao = PendingScrobblesDb.getDatabase(ctx).pendingScrobblesDao() + PendingScrobblesRepository(pendingDao) + .insertScrobble(pendingScrobble) + Log.d("ScrobbleWorker", "scrobble has been saved offline") + Result.failure() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/musicorum/mobile/workers/SyncScrobblesWorker.kt b/app/src/main/java/io/musicorum/mobile/workers/SyncScrobblesWorker.kt new file mode 100644 index 0000000..3c5e338 --- /dev/null +++ b/app/src/main/java/io/musicorum/mobile/workers/SyncScrobblesWorker.kt @@ -0,0 +1,55 @@ +package io.musicorum.mobile.workers + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import io.ktor.client.plugins.ClientRequestException +import io.ktor.http.HttpStatusCode +import io.musicorum.mobile.database.PendingScrobblesDb +import io.musicorum.mobile.datastore.UserData +import io.musicorum.mobile.ktor.endpoints.UserEndpoint +import io.musicorum.mobile.repositories.PendingScrobblesRepository +import io.musicorum.mobile.userData +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class SyncScrobblesWorker(val ctx: Context, workerParams: WorkerParameters) : + CoroutineWorker(ctx, workerParams) { + //val context = ctx + + override suspend fun doWork(): Result { + val pendingDatabase = PendingScrobblesDb.getDatabase(ctx) + val repository = PendingScrobblesRepository(pendingDatabase.pendingScrobblesDao()) + val sessionKey = ctx.userData.data.map { + it[UserData.SESSION_KEY] + }.first() ?: return Result.failure() + + val scrobbles = repository.getAllScrobblesStream().first() + try { + if (scrobbles.isEmpty()) { + return Result.success() + } + for (scrobble in scrobbles) { + val code = UserEndpoint.scrobble( + track = scrobble.trackName, + artist = scrobble.artistName, + album = scrobble.album, + albumArtist = scrobble.artistName, + sessionKey = sessionKey, + timestamp = scrobble.timestamp / 1000 + ) + + if (code == HttpStatusCode.OK) { + repository.deleteScrobble(scrobble) + } + } + return Result.success() + } catch (e: ClientRequestException) { + return if (e.response.status == HttpStatusCode.TooManyRequests) { + Result.retry() + } else { + Result.failure() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3fc9104..d7f1d04 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -148,8 +148,26 @@ Sharing item… Starting download… Report what happened when the app crashed + Pending Scrobbles + Last sync status: %1$s + You have no pending scrobbles + Scrobble Settings + Search on Last.fm… + No results + Discover + NOW PLAYING + Scrobbling since %1$s + Similar to %1$s + Album Tracks + Failed to fetch album + Something went wrong Enabled • %1$d app Enabled • %1$d apps + + + result + results + \ No newline at end of file