From b8e56ecc4439cd626a7588610dabfff8df07fc24 Mon Sep 17 00:00:00 2001 From: Gunny Date: Tue, 7 Jul 2026 19:02:32 +0530 Subject: [PATCH 1/8] feat(lastfm): add Last.fm data models --- .../data/network/lastfm/LastFmModels.kt | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmModels.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmModels.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmModels.kt new file mode 100644 index 000000000..10aa8397a --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmModels.kt @@ -0,0 +1,82 @@ +package com.theveloper.pixelplay.data.network.lastfm + +import com.google.gson.annotations.SerializedName + +// --------------------------------------------------------------------------- +// auth.getMobileSession response +// Ref: https://www.last.fm/api/mobileauth +// --------------------------------------------------------------------------- +data class LastFmSessionResponse( + @SerializedName("session") val session: LastFmSession? +) + +data class LastFmSession( + @SerializedName("name") val name: String, + @SerializedName("key") val key: String, + @SerializedName("subscriber") val subscriber: Int +) + +// --------------------------------------------------------------------------- +// track.updateNowPlaying response +// Ref: https://www.last.fm/api/show/track.updateNowPlaying +// --------------------------------------------------------------------------- +data class LastFmNowPlayingResponse( + @SerializedName("nowplaying") val nowPlaying: LastFmNowPlayingResult? +) + +data class LastFmNowPlayingResult( + @SerializedName("artist") val artist: LastFmCorrectedValue?, + @SerializedName("track") val track: LastFmCorrectedValue?, + @SerializedName("album") val album: LastFmCorrectedValue?, + @SerializedName("albumartist") val albumArtist: LastFmCorrectedValue? +) + +// --------------------------------------------------------------------------- +// track.scrobble response +// Ref: https://www.last.fm/api/show/track.scrobble +// --------------------------------------------------------------------------- +data class LastFmScrobbleResponse( + @SerializedName("scrobbles") val scrobbles: LastFmScrobblesWrapper? +) + +data class LastFmScrobblesWrapper( + @SerializedName("scrobble") val scrobble: LastFmScrobbleResult?, + @SerializedName("@attr") val attr: LastFmScrobblesAttr? +) + +data class LastFmScrobbleResult( + @SerializedName("artist") val artist: LastFmCorrectedValue?, + @SerializedName("track") val track: LastFmCorrectedValue?, + @SerializedName("album") val album: LastFmCorrectedValue?, + @SerializedName("ignoredMessage") val ignoredMessage: LastFmIgnoredMessage? +) + +data class LastFmIgnoredMessage( + @SerializedName("code") val code: Int, + @SerializedName("#text") val text: String +) + +data class LastFmScrobblesAttr( + @SerializedName("accepted") val accepted: Int, + @SerializedName("ignored") val ignored: Int +) + +/** + * Shared wrapper for correctable string values returned by Last.fm + * (artist, track, album). The `corrected` field is "1" if Last.fm + * applied a correction, "0" otherwise. + */ +data class LastFmCorrectedValue( + @SerializedName("#text") val text: String, + @SerializedName("corrected") val corrected: String +) + +// --------------------------------------------------------------------------- +// Error envelope +// Last.fm returns HTTP 200 even on errors, with a JSON body: +// { "error": , "message": "" } +// --------------------------------------------------------------------------- +data class LastFmErrorResponse( + @SerializedName("error") val error: Int, + @SerializedName("message") val message: String +) From 88aa211ed6cd4b3e3429478ec6e5a2627d6ed41a Mon Sep 17 00:00:00 2001 From: Gunny Date: Tue, 7 Jul 2026 19:03:37 +0530 Subject: [PATCH 2/8] feat(lastfm): add MD5 signature builder (auth spec section 8) Implements the buildLastFmSignature function to create the api_sig parameter for Last.fm API calls. --- .../data/network/lastfm/LastFmAuthHelper.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmAuthHelper.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmAuthHelper.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmAuthHelper.kt new file mode 100644 index 000000000..d6261b4e8 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmAuthHelper.kt @@ -0,0 +1,36 @@ +package com.theveloper.pixelplay.data.network.lastfm + +import java.security.MessageDigest + +/** + * Builds the api_sig parameter required by all Last.fm write calls. + * + * Algorithm per https://www.last.fm/api/authspec (Section 8): + * 1. Collect all call parameters EXCEPT `format` and `callback`. + * 2. Sort them alphabetically by key. + * 3. Concatenate as "key1value1key2value2...". + * 4. Append the shared secret. + * 5. MD5-hash the resulting UTF-8 string. + * + * @param params The full set of request parameters (excluding format/callback). + * @param secret The application shared secret from last.fm/api/accounts. + * @return 32-character lowercase hex MD5 digest. + */ +fun buildLastFmSignature(params: Map, secret: String): String { + val sorted = params + .filterKeys { it != "format" && it != "callback" } + .entries + .sortedBy { it.key } + + val sigString = buildString { + for ((k, v) in sorted) { + append(k) + append(v) + } + append(secret) + } + + val md = MessageDigest.getInstance("MD5") + val bytes = md.digest(sigString.toByteArray(Charsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } +} From 7c645088386c59b68acf32f5e994e61d5526500b Mon Sep 17 00:00:00 2001 From: Gunny Date: Tue, 7 Jul 2026 19:04:38 +0530 Subject: [PATCH 3/8] feat(lastfm): add Retrofit API service interface This interface defines methods for authenticating users and notifying Last.fm of track playback. It includes methods for mobile session authentication, updating the currently playing track, and scrobbling tracks. --- .../data/network/lastfm/LastFmApiService.kt | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmApiService.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmApiService.kt b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmApiService.kt new file mode 100644 index 000000000..fbe565d97 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/network/lastfm/LastFmApiService.kt @@ -0,0 +1,71 @@ +package com.theveloper.pixelplay.data.network.lastfm + +import retrofit2.http.FieldMap +import retrofit2.http.FormUrlEncoded +import retrofit2.http.POST + +/** + * Retrofit interface for the Last.fm API. + * + * All write-capable methods (auth, scrobble, nowPlaying) use POST over HTTPS + * as required by Last.fm documentation. + * + * Base URL: https://ws.audioscrobbler.com/2.0/ + * + * Each call includes: + * - method : the Last.fm API method name + * - api_key : application key (injected via BuildConfig) + * - api_sig : HMAC-style MD5 signature (see LastFmAuthHelper) + * - format : "json" (so responses are JSON, not XML) + * + * Refs: + * https://www.last.fm/api/mobileauth + * https://www.last.fm/api/show/track.updateNowPlaying + * https://www.last.fm/api/show/track.scrobble + */ +interface LastFmApiService { + + /** + * Authenticate a user via username + password (mobile auth flow). + * Returns a session key to be stored and reused for all subsequent calls. + * + * Required fields: method, username, password, api_key, api_sig + * The `method` value MUST be "auth.getMobileSession". + */ + @FormUrlEncoded + @POST(".") + suspend fun getMobileSession( + @FieldMap fields: Map + ): LastFmSessionResponse + + /** + * Notify Last.fm that the user has started listening to a track. + * Should be called at the start of playback. + * + * Required fields: method, artist, track, api_key, api_sig, sk + * Optional: album, trackNumber, duration, albumArtist + * The `method` value MUST be "track.updateNowPlaying". + */ + @FormUrlEncoded + @POST(".") + suspend fun updateNowPlaying( + @FieldMap fields: Map + ): LastFmNowPlayingResponse + + /** + * Scrobble a single track (record a play in the user's history). + * + * Per Scrobbling 2.0 spec: + * - Track must be longer than 30 seconds. + * - Track must have been played for at least half its duration OR 4 minutes. + * + * Required fields: method, artist[0], track[0], timestamp[0], api_key, api_sig, sk + * Optional: album[0], trackNumber[0], duration[0], albumArtist[0] + * The `method` value MUST be "track.scrobble". + */ + @FormUrlEncoded + @POST(".") + suspend fun scrobble( + @FieldMap fields: Map + ): LastFmScrobbleResponse +} From 6c5c560f21c80cdff014467bb315be927949561d Mon Sep 17 00:00:00 2001 From: Gunny Date: Tue, 7 Jul 2026 19:06:05 +0530 Subject: [PATCH 4/8] feat(lastfm): add LastFmRepository with login, logout, scrobble, nowPlaying This class manages Last.fm authentication, session key storage, and scrobbling operations. It includes methods for logging in, logging out, updating now playing, and scrobbling tracks. --- .../pixelplay/data/lastfm/LastFmRepository.kt | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/lastfm/LastFmRepository.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/lastfm/LastFmRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/lastfm/LastFmRepository.kt new file mode 100644 index 000000000..2495cb0c4 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/lastfm/LastFmRepository.kt @@ -0,0 +1,209 @@ +package com.theveloper.pixelplay.data.lastfm + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.theveloper.pixelplay.BuildConfig +import com.theveloper.pixelplay.data.network.lastfm.LastFmApiService +import com.theveloper.pixelplay.data.network.lastfm.buildLastFmSignature +import com.theveloper.pixelplay.data.preferences.dataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages Last.fm authentication state, session key storage, + * and the two active scrobbling operations: + * - updateNowPlaying : called when a track starts + * - scrobble : called once the track qualifies (>30 s played + * AND >= half duration or 4 minutes) + * + * Auth uses the mobile auth flow (auth.getMobileSession) which accepts + * a username + password directly over HTTPS — no browser redirect needed. + * + * Ref: https://www.last.fm/api/mobileauth + */ +@Singleton +class LastFmRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val apiService: LastFmApiService +) { + + // ----------------------------------------------------------------------- + // DataStore keys + // ----------------------------------------------------------------------- + private val KEY_SESSION = stringPreferencesKey("lastfm_session_key") + private val KEY_USERNAME = stringPreferencesKey("lastfm_username") + + // ----------------------------------------------------------------------- + // Public state + // ----------------------------------------------------------------------- + + /** Emits the stored session key, or null when not logged in. */ + val sessionKeyFlow: Flow = context.dataStore.data + .map { prefs -> prefs[KEY_SESSION] } + + /** Emits the Last.fm username, or null when not logged in. */ + val usernameFlow: Flow = context.dataStore.data + .map { prefs -> prefs[KEY_USERNAME] } + + /** True when a valid session key is persisted. */ + val isLoggedInFlow: Flow = sessionKeyFlow.map { it != null } + + // Internal cache so DualPlayerEngine can read synchronously + private val _sessionKey = MutableStateFlow(null) + val sessionKey = _sessionKey.asStateFlow() + + // ----------------------------------------------------------------------- + // Auth + // ----------------------------------------------------------------------- + + /** + * Authenticate with Last.fm using username + password. + * On success, persists the session key and username to DataStore. + * + * @return true on success, false on failure. + */ + suspend fun login(username: String, password: String): Boolean { + return try { + val params = buildParams( + "method" to "auth.getMobileSession", + "username" to username, + "password" to password + ) + val response = apiService.getMobileSession(params) + val key = response.session?.key + if (key != null) { + context.dataStore.edit { prefs -> + prefs[KEY_SESSION] = key + prefs[KEY_USERNAME] = username + } + _sessionKey.value = key + Timber.tag(TAG).i("Last.fm login successful for $username") + true + } else { + Timber.tag(TAG).w("Last.fm login returned null session") + false + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Last.fm login failed") + false + } + } + + /** Clear the stored session and mark the user as logged out. */ + suspend fun logout() { + context.dataStore.edit { prefs -> + prefs.remove(KEY_SESSION) + prefs.remove(KEY_USERNAME) + } + _sessionKey.value = null + Timber.tag(TAG).i("Last.fm session cleared") + } + + /** Warm the in-memory cache from DataStore on app start. */ + suspend fun warmSessionCache(sk: String?) { + _sessionKey.value = sk + } + + // ----------------------------------------------------------------------- + // Scrobbling + // ----------------------------------------------------------------------- + + /** + * Notify Last.fm that the user has started playing a track. + * Should be called immediately when a track begins. + * + * Ref: https://www.last.fm/api/show/track.updateNowPlaying + */ + suspend fun updateNowPlaying( + artist: String, + track: String, + album: String? = null, + duration: Long? = null + ) { + val sk = _sessionKey.value ?: return + try { + val extra = mutableMapOf( + "method" to "track.updateNowPlaying", + "artist" to artist, + "track" to track, + "sk" to sk + ) + album?.let { extra["album"] = it } + duration?.let { extra["duration"] = it.toString() } + val params = buildParams(*extra.entries.map { it.key to it.value }.toTypedArray()) + apiService.updateNowPlaying(params) + Timber.tag(TAG).d("Now playing: $artist - $track") + } catch (e: Exception) { + Timber.tag(TAG).w(e, "updateNowPlaying failed") + } + } + + /** + * Scrobble a track to Last.fm. + * + * Call this once the track qualifies per the Scrobbling 2.0 spec: + * - Track duration > 30 seconds. + * - Track has been played for >= half its duration, or >= 4 minutes. + * + * @param startTimestamp UNIX UTC timestamp (seconds) when playback started. + * + * Ref: https://www.last.fm/api/show/track.scrobble + * https://www.last.fm/api/scrobbling + */ + suspend fun scrobble( + artist: String, + track: String, + startTimestamp: Long, + album: String? = null, + duration: Long? = null + ) { + val sk = _sessionKey.value ?: return + try { + val extra = mutableMapOf( + "method" to "track.scrobble", + "artist[0]" to artist, + "track[0]" to track, + "timestamp[0]" to startTimestamp.toString(), + "sk" to sk + ) + album?.let { extra["album[0]"] = it } + duration?.let { extra["duration[0]"] = it.toString() } + val params = buildParams(*extra.entries.map { it.key to it.value }.toTypedArray()) + val response = apiService.scrobble(params) + val accepted = response.scrobbles?.attr?.accepted ?: 0 + val ignored = response.scrobbles?.attr?.ignored ?: 0 + Timber.tag(TAG).d("Scrobbled $artist - $track (accepted=$accepted, ignored=$ignored)") + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Scrobble failed") + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Build the full parameter map for a Last.fm API call: + * - Adds api_key and format automatically. + * - Computes and appends api_sig per the auth spec. + */ + private fun buildParams(vararg pairs: Pair): Map { + val map = mutableMapOf(*pairs) + map["api_key"] = BuildConfig.LASTFM_API_KEY + map["format"] = "json" + // api_sig is computed BEFORE adding format (format is excluded per spec) + map["api_sig"] = buildLastFmSignature(map, BuildConfig.LASTFM_SHARED_SECRET) + return map + } + + companion object { + private const val TAG = "LastFmRepository" + } +} From baeda2440eb602daa56d043b07e94ccd5fde9504 Mon Sep 17 00:00:00 2001 From: Gunny Date: Tue, 7 Jul 2026 19:12:23 +0530 Subject: [PATCH 5/8] feat(lastfm): wire LastFmApiService and LastFmRepository into AppModule DI --- .../com/theveloper/pixelplay/di/AppModule.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt index 7f1d93599..643d64ab2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt @@ -586,4 +586,51 @@ object AppModule { ): ArtistImageRepository { return ArtistImageRepository(deezerApiService, musicDao) } + + /** + * Qualifier for the Last.fm Retrofit instance. + */ + @Qualifier + @Retention(AnnotationRetention.BINARY) + annotation class LastFmRetrofit + + /** + * Provides a Retrofit instance configured for the Last.fm API. + * Base URL: https://ws.audioscrobbler.com/ + */ + @Provides + @Singleton + @LastFmRetrofit + fun provideLastFmRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl("https://ws.audioscrobbler.com/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + /** + * Provides the Last.fm API service. + */ + @Provides + @Singleton + fun provideLastFmApiService(@LastFmRetrofit retrofit: Retrofit): com.theveloper.pixelplay.data.network.lastfm.LastFmApiService { + return retrofit.create(com.theveloper.pixelplay.data.network.lastfm.LastFmApiService::class.java) + } + + /** + * Provides the Last.fm repository for scrobbling and now-playing. + */ + @Provides + @Singleton + fun provideLastFmRepository( + lastFmApiService: com.theveloper.pixelplay.data.network.lastfm.LastFmApiService, + userPreferencesRepository: UserPreferencesRepository + ): com.theveloper.pixelplay.data.lastfm.LastFmRepository { + return com.theveloper.pixelplay.data.lastfm.LastFmRepositoryImpl( + lastFmApiService = lastFmApiService, + userPreferencesRepository = userPreferencesRepository + ) + } + } From bb79d45857842b30a46d2fd80992d4bd36b5f669 Mon Sep 17 00:00:00 2001 From: Gunny Date: Tue, 7 Jul 2026 19:14:05 +0530 Subject: [PATCH 6/8] feat(lastfm): add session key/username storage to UserPreferencesRepository Added Last.fm session management functions and flows. --- .../preferences/UserPreferencesRepository.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index 4598f1a86..5401c3318 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -1386,4 +1386,48 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) { /** Increments [value] by 1, wrapping back to 0 on overflow. */ private fun incrementWrapped(value: Int?) = if (value == null || value == Int.MAX_VALUE) 0 else value + 1 + + // ---- Last.fm ------------------------------------------------------- + + private object LastFmKeys { + val SESSION_KEY = stringPreferencesKey("lastfm_session_key") + val USERNAME = stringPreferencesKey("lastfm_username") + val SCROBBLING_ENABLED = booleanPreferencesKey("lastfm_scrobbling_enabled") + } + + /** Returns the stored Last.fm session key, or null if not logged in. */ + val lastFmSessionKeyFlow: Flow = + dataStore.data.map { it[LastFmKeys.SESSION_KEY] } + + /** Returns the stored Last.fm username, or null if not logged in. */ + val lastFmUsernameFlow: Flow = + dataStore.data.map { it[LastFmKeys.USERNAME] } + + /** Returns whether Last.fm scrobbling is enabled (defaults true when logged in). */ + val lastFmScrobblingEnabledFlow: Flow = + dataStore.data.map { it[LastFmKeys.SCROBBLING_ENABLED] ?: true } + + /** Persists the Last.fm session key and username after a successful auth. */ + suspend fun setLastFmSession(sessionKey: String, username: String) { + dataStore.edit { + it[LastFmKeys.SESSION_KEY] = sessionKey + it[LastFmKeys.USERNAME] = username + it[LastFmKeys.SCROBBLING_ENABLED] = true + } + } + + /** Clears all Last.fm credentials (logout). */ + suspend fun clearLastFmSession() { + dataStore.edit { + it.remove(LastFmKeys.SESSION_KEY) + it.remove(LastFmKeys.USERNAME) + it.remove(LastFmKeys.SCROBBLING_ENABLED) + } + } + + /** Enables or disables Last.fm scrobbling without logging out. */ + suspend fun setLastFmScrobblingEnabled(enabled: Boolean) { + dataStore.edit { it[LastFmKeys.SCROBBLING_ENABLED] = enabled } + } + } From 720d6d5fbf4c8c10a010ba81dd5db68f1b0d8078 Mon Sep 17 00:00:00 2001 From: Gunny Date: Tue, 7 Jul 2026 19:26:11 +0530 Subject: [PATCH 7/8] feat(lastfm): hook scrobble/now-playing into MusicService.onMediaItemTransition Added notifyLastFmNowPlaying function to handle media item updates. --- .../pixelplay/data/service/MusicService.kt | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index adab461ca..271199f1b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -1426,8 +1426,9 @@ class MusicService : MediaLibraryService() { // Let the debounced updater handle it to prevent UI freezes. widgetUpdateManager.requestFullUpdate(false) mediaSession?.let { refreshMediaSessionUi(it) } - schedulePlaybackSnapshotPersist() - } + schedulePlaybackSnapshotPersist() + notifyLastFmNowPlaying(mediaItem) + } override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { // Some devices/apps deliver title/artist/art after transition callback. @@ -2913,4 +2914,28 @@ class MusicService : MediaLibraryService() { } return future } + + // ---- Last.fm ------------------------------------------------------- + @Inject + lateinit var lastFmRepository: com.theveloper.pixelplay.data.lastfm.LastFmRepository + + /** + * Triggers Last.fm updateNowPlaying + schedules a scrobble for [mediaItem]. + * Called from [playerListener.onMediaItemTransition]. + */ + private fun notifyLastFmNowPlaying(mediaItem: MediaItem?) { + mediaItem ?: return + val track = mediaItem.mediaMetadata.title?.toString()?.takeIf { it.isNotBlank() } ?: return + val artist = mediaItem.mediaMetadata.artist?.toString()?.takeIf { it.isNotBlank() } ?: return + val album = mediaItem.mediaMetadata.albumTitle?.toString() + val durationMs = mediaItem.mediaMetadata.extras + ?.getLong("duration", 0L) ?: 0L + engine.onLastFmTrackStarted( + track = track, + artist = artist, + album = album, + durationMs = durationMs + ) + } + } From b537b0c6106dd2ca8dada3146e46adbf4252dfeb Mon Sep 17 00:00:00 2001 From: Gunny Date: Tue, 7 Jul 2026 19:29:21 +0530 Subject: [PATCH 8/8] feat(lastfm): add LAST_FM account, login/logout/state to AccountsViewModel Added LastFmRepository integration for Last.fm authentication and account management. --- .../viewmodel/AccountsViewModel.kt | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt index 354d92987..c56ea1e6a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AccountsViewModel.kt @@ -9,6 +9,7 @@ import com.theveloper.pixelplay.data.netease.NeteaseRepository import com.theveloper.pixelplay.data.qqmusic.QqMusicRepository import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.data.telegram.TelegramRepository +import com.theveloper.pixelplay.data.lastfm.LastFmRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -28,7 +29,8 @@ enum class ExternalServiceAccount { NETEASE, QQ_MUSIC, NAVIDROME, - JELLYFIN + JELLYFIN, + LAST_FM } data class ExternalAccountUiModel( @@ -52,7 +54,8 @@ class AccountsViewModel @Inject constructor( private val neteaseRepository: NeteaseRepository, private val qqMusicRepository: QqMusicRepository, private val navidromeRepository: NavidromeRepository, - private val jellyfinRepository: JellyfinRepository + private val jellyfinRepository: JellyfinRepository, + private val lastFmRepository: LastFmRepository ) : ViewModel() { private val loggingOutServices = MutableStateFlow>(emptySet()) @@ -259,6 +262,7 @@ class AccountsViewModel @Inject constructor( ExternalServiceAccount.QQ_MUSIC -> qqMusicRepository.logout() ExternalServiceAccount.NAVIDROME -> navidromeRepository.logout() ExternalServiceAccount.JELLYFIN -> jellyfinRepository.logout() + ExternalServiceAccount.LAST_FM -> lastFmRepository.logout() } } } finally { @@ -274,4 +278,32 @@ class AccountsViewModel @Inject constructor( "$count $plural" } } + + // ---- Last.fm ------------------------------------------------------- + + /** One-shot UI state for Last.fm auth: null = idle, true = success, false = failed. */ + private val _lastFmLoginResult = MutableStateFlow?>(null) + val lastFmLoginResult: StateFlow?> = _lastFmLoginResult + + /** Last.fm username from persisted preferences, or null if not logged in. */ + val lastFmUsername: StateFlow = lastFmRepository.usernameFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + /** + * Authenticates with Last.fm using the Mobile Auth flow (username + password → session key). + * https://www.last.fm/api/mobileauth + */ + fun loginLastFm(username: String, password: String) { + viewModelScope.launch { + _lastFmLoginResult.value = null + val result = runCatching { lastFmRepository.login(username, password) } + _lastFmLoginResult.value = result + } + } + + /** Clears the one-shot login result after the UI has consumed it. */ + fun consumeLastFmLoginResult() { + _lastFmLoginResult.value = null + } + }