Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -285,4 +285,5 @@ dependencies {
coreLibraryDesugaring(libs.desugaring)

implementation(libs.timber)
implementation(libs.security.crypto)
}
Empty file modified app/generate_proto.sh
100644 → 100755
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,13 @@ val DiscordActivityTypeKey = stringPreferencesKey("discordActivityType")
val DiscordActivityNameKey = stringPreferencesKey("discordActivityName")
val DiscordAdvancedModeKey = booleanPreferencesKey("discordAdvancedMode")

// Matrix RPC
val MatrixAccountsKey = stringPreferencesKey("matrixAccounts")
val EnableMatrixRPCKey = booleanPreferencesKey("matrixRPCEnable")
val MatrixStatusFormatKey = stringPreferencesKey("matrixStatusFormat")
val MatrixUpdateIntervalKey = intPreferencesKey("matrixUpdateInterval")
val MatrixClearStatusKey = booleanPreferencesKey("matrixClearStatus")

// Google Cast
val EnableGoogleCastKey = booleanPreferencesKey("enableGoogleCast")

Expand Down
18 changes: 18 additions & 0 deletions app/src/main/kotlin/com/metrolist/music/models/MatrixAccount.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Metrolist Project (C) 2026
* Licensed under GPL-3.0 | See git history for contributors
*/

package com.metrolist.music.models

import kotlinx.serialization.Serializable

@Serializable
data class MatrixAccount(
val homeserver: String,
val userId: String,
) {
override fun toString(): String {
return "MatrixAccount(homeserver='$homeserver', userId='$userId')"
}
}
222 changes: 219 additions & 3 deletions app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ import com.metrolist.music.constants.DisableLoadMoreWhenRepeatAllKey
import com.metrolist.music.constants.DiscordActivityNameKey
import com.metrolist.music.constants.DiscordActivityTypeKey
import com.metrolist.music.constants.DiscordAdvancedModeKey
import com.metrolist.music.constants.DiscordAvatarKey
import com.metrolist.music.constants.DiscordButton1TextKey
import com.metrolist.music.constants.DiscordButton1VisibleKey
import com.metrolist.music.constants.DiscordButton2TextKey
Expand All @@ -104,6 +103,13 @@ import com.metrolist.music.constants.DiscordStatusKey
import com.metrolist.music.constants.DiscordTokenKey
import com.metrolist.music.constants.DiscordUseDetailsKey
import com.metrolist.music.constants.EnableDiscordRPCKey
import com.metrolist.music.constants.EnableMatrixRPCKey
import com.metrolist.music.constants.MatrixAccountsKey
import com.metrolist.music.constants.MatrixStatusFormatKey
import com.metrolist.music.constants.MatrixUpdateIntervalKey
import com.metrolist.music.constants.MatrixClearStatusKey
import com.metrolist.music.models.MatrixAccount
import kotlinx.serialization.json.Json
import com.metrolist.music.constants.EnableLastFMScrobblingKey
import com.metrolist.music.constants.EnableSongCacheKey
import com.metrolist.music.constants.HideExplicitKey
Expand Down Expand Up @@ -137,7 +143,6 @@ import com.metrolist.music.constants.SkipSilenceKey
import com.metrolist.music.db.entities.Event
import com.metrolist.music.db.entities.FormatEntity
import com.metrolist.music.db.entities.LyricsEntity
import com.metrolist.music.db.entities.PlaylistEntity
import com.metrolist.music.db.entities.RelatedSongMap
import com.metrolist.music.db.entities.Song
import com.metrolist.music.db.MusicDatabase
Expand Down Expand Up @@ -176,6 +181,10 @@ import com.metrolist.music.R
import com.metrolist.music.utils.CoilBitmapLoader
import com.metrolist.music.utils.dataStore
import com.metrolist.music.utils.DiscordRPC
import com.metrolist.music.utils.MatrixRPC
import com.metrolist.music.utils.MatrixTokenStore
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import com.metrolist.music.utils.get
import com.metrolist.music.utils.NetworkConnectivityObserver
import com.metrolist.music.utils.reportException
Expand Down Expand Up @@ -372,9 +381,17 @@ class MusicService :
private var loudnessEnhancer: LoudnessEnhancer? = null

private var discordRpc: DiscordRPC? = null
private var matrixRpcClients = mutableListOf<MatrixRPC>()
private val matrixRpcClientsMutex = Mutex()
private val matrixRpcUpdateMutex = Mutex()
private var lastPlaybackSpeed = 1.0f
private var discordUpdateJob: kotlinx.coroutines.Job? = null
private var matrixUpdateJob: kotlinx.coroutines.Job? = null
Comment thread
gamesguru marked this conversation as resolved.
private var matrixToastJob: kotlinx.coroutines.Job? = null

private val matrixJsonConfig = Json { ignoreUnknownKeys = true }

// MediaSession components
private var scrobbleManager: ScrobbleManager? = null

val automixItems = MutableStateFlow<List<MediaItem>>(emptyList())
Expand Down Expand Up @@ -413,6 +430,7 @@ class MusicService :
if (!player.isPlaying) {
scope.launch(Dispatchers.IO) {
discordRpc?.closeRPC()
clearMatrixRPC()
}
}
}
Expand All @@ -422,6 +440,7 @@ class MusicService :
scope.launch {
currentSong.value?.let { song ->
updateDiscordRPC(song)
updateMatrixRPC(song)
}
}
}
Expand Down Expand Up @@ -635,6 +654,14 @@ class MusicService :
}
}
}
if (isConnected && matrixRpcClientsMutex.withLock { matrixRpcClients.isNotEmpty() } && player.isPlaying) {
val mediaId = player.currentMetadata?.id
if (mediaId != null) {
database.song(mediaId).first()?.let { song ->
updateMatrixRPC(song)
}
}
}
}
}

Expand Down Expand Up @@ -810,6 +837,76 @@ class MusicService :
}
}

// Matrix Account Sync: Always keep clients loaded regardless of toggle
dataStore.data
.map { it[MatrixAccountsKey] }
.distinctUntilChanged()
.collect(scope) { accountsJson ->
val actualAccountsJson = accountsJson ?: "[]"
matrixRpcClientsMutex.withLock {
matrixRpcClients.clear()
val accounts = try {
matrixJsonConfig.decodeFromString<List<MatrixAccount>>(actualAccountsJson)
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to decode Matrix accounts (JSON: $actualAccountsJson)")
emptyList()
}

accounts.forEach { account ->
val token = MatrixTokenStore.getToken(this@MusicService, account.homeserver, account.userId)
if (!token.isNullOrEmpty()) {
matrixRpcClients.add(
MatrixRPC(
homeserver = account.homeserver,
userId = account.userId,
accessToken = token,
listeningPrefix = getString(R.string.matrix_listening_prefix),
pausedPrefix = getString(R.string.matrix_paused_prefix)
)
)
}
}
}
// Trigger update/clear based on current state
if (dataStore.get(EnableMatrixRPCKey, false) == true) {
updateMatrixRPC(currentSong.value)
} else {
clearMatrixRPC()
}
}

// Matrix Toggle: Gate network calls
dataStore.data
.map { it[EnableMatrixRPCKey] ?: false }
.distinctUntilChanged()
.collect(scope) { enabled ->
if (enabled) {
updateMatrixRPC(currentSong.value)
} else {
clearMatrixRPC()
}
}

dataStore.data
.map { it[MatrixStatusFormatKey] }
.debounce(300)
.distinctUntilChanged()
.collect(scope) {
if (player.playbackState == Player.STATE_READY && player.playWhenReady) {
updateMatrixRPC(currentSong.value)
}
}

dataStore.data
.map { it[MatrixClearStatusKey] ?: false }
.distinctUntilChanged()
.collect(scope) { clearRequested ->
if (clearRequested) {
clearMatrixRPC(showToast = true)
dataStore.edit { it[MatrixClearStatusKey] = false }
}
}

// Watch all Discord customization preferences
dataStore.data
.map {
Expand Down Expand Up @@ -1832,7 +1929,7 @@ class MusicService :
}
}
}

private suspend fun toggleEpisodeSaveForLater(songEntity: com.metrolist.music.db.entities.SongEntity) {
val isCurrentlySaved = songEntity.inLibrary != null
val shouldBeSaved = !isCurrentlySaved
Expand Down Expand Up @@ -2137,6 +2234,7 @@ class MusicService :

if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) {
scrobbleManager?.onSongStop()
clearMatrixRPC()
}
}

Expand Down Expand Up @@ -2211,6 +2309,7 @@ class MusicService :
) {
scope.launch {
discordRpc?.close()
updateMatrixRPC(currentSong.value)
}
}
}
Expand All @@ -2227,6 +2326,7 @@ class MusicService :
// Fetch song from database to get full info
database.song(mediaId).first()?.let { song ->
updateDiscordRPC(song)
updateMatrixRPC(song)
}
}
}
Expand Down Expand Up @@ -2338,6 +2438,7 @@ class MusicService :
if (player.playWhenReady && player.playbackState == Player.STATE_READY) {
currentSong.value?.let { song ->
updateDiscordRPC(song)
updateMatrixRPC(song)
}
}
}
Expand Down Expand Up @@ -2868,6 +2969,121 @@ class MusicService :
}
}

/**
* Updates the status on all connected Matrix RPC clients.
* This handles debouncing and respects the global Matrix RPC enabled toggle.
*
* @param song The [Song] metadata to broadcast, or null to clear.
*/
private fun updateMatrixRPC(song: Song?) {
val current = song ?: currentSong.value ?: return

// IMMEDIATE GATE: If presence is disabled, clear once and stop everything.
if (dataStore.get(EnableMatrixRPCKey, false) != true) {
clearMatrixRPC()
return
}

matrixUpdateJob?.cancel()
matrixUpdateJob = scope.launch {
// Debounce rapid metadata/playback changes without holding the mutex.
delay(1000)

// RE-CHECK AFTER DELAY: Just in case it was disabled during the 1s wait.
if (dataStore.get(EnableMatrixRPCKey, false) != true) {
clearMatrixRPC()
return@launch
}

var repeatUpdate = false
var updateIntervalSeconds = 15
matrixRpcUpdateMutex.withLock {
val presence = if (player.isPlaying) "online" else "unavailable"
val statusFormat = dataStore.get(MatrixStatusFormatKey, "").ifEmpty { getString(R.string.matrix_status_format_default) }
val intervalSeconds = dataStore.get(MatrixUpdateIntervalKey, 15)

val clients = matrixRpcClientsMutex.withLock { matrixRpcClients.toList() }
clients.forEach { client ->
client.updateSong(
song = current,
currentPositionMs = player.currentPosition,
statusFormat = statusFormat,
presence = presence
).onFailure {
Timber.tag(TAG).w(it, "Matrix RPC update failed")
}
}
Comment thread
gamesguru marked this conversation as resolved.
Outdated

if (player.playWhenReady && player.playbackState == Player.STATE_READY && player.isPlaying) {
repeatUpdate = true
updateIntervalSeconds = intervalSeconds
}
}

if (repeatUpdate) {
delay(updateIntervalSeconds * 1000L)
updateMatrixRPC(current)
}
}
}

/**
* Clears the current Matrix status on all connected clients.
*
* @param showToast Whether to show a localized toast message when status is cleared.
*/
private fun clearMatrixRPC(showToast: Boolean = false) {
matrixUpdateJob?.cancel()
matrixUpdateJob = scope.launch {
var count = 0
val clientsToClear = matrixRpcUpdateMutex.withLock {
matrixRpcClientsMutex.withLock {
if (matrixRpcClients.isEmpty()) {
// Always attempt to load accounts when clearing, even if disabled
val accountsJson = dataStore.get(MatrixAccountsKey, "[]")
val accounts = try {
matrixJsonConfig.decodeFromString<List<MatrixAccount>>(accountsJson)
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to decode Matrix accounts for clearing (JSON: $accountsJson)")
emptyList()
}

accounts.forEach { account ->
val token = MatrixTokenStore.getToken(this@MusicService, account.homeserver, account.userId)
if (!token.isNullOrEmpty()) {
matrixRpcClients.add(
MatrixRPC(
homeserver = account.homeserver,
userId = account.userId,
accessToken = token,
listeningPrefix = getString(R.string.matrix_listening_prefix),
pausedPrefix = getString(R.string.matrix_paused_prefix)
)
)
}
}
}

matrixRpcClients.toList()
}
}

count = clientsToClear.size
clientsToClear.forEach { it.clearStatus() }

if (showToast && count > 0) {
matrixToastJob?.cancel()
matrixToastJob = scope.launch(Dispatchers.Main) {
android.widget.Toast.makeText(
this@MusicService,
resources.getQuantityString(R.plurals.matrix_status_cleared, count, count),
android.widget.Toast.LENGTH_SHORT
).show()
}
}
}
}

private fun updateDiscordRPC(song: Song, showFeedback: Boolean = false) {
val useDetails = dataStore.get(DiscordUseDetailsKey, false)
val advancedMode = dataStore.get(DiscordAdvancedModeKey, false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import com.metrolist.music.ui.screens.settings.integrations.DiscordSettings
import com.metrolist.music.ui.screens.settings.integrations.IntegrationScreen
import com.metrolist.music.ui.screens.settings.integrations.LastFMSettings
import com.metrolist.music.ui.screens.settings.integrations.ListenTogetherSettings
import com.metrolist.music.ui.screens.settings.integrations.MatrixSettings
import com.metrolist.music.ui.screens.wrapped.WrappedScreen
import com.metrolist.music.utils.rememberEnumPreference
import com.metrolist.music.utils.rememberPreference
Expand Down Expand Up @@ -393,6 +394,10 @@ fun NavGraphBuilder.navigationBuilder(
DiscordSettings(navController, snackbarHostState)
}

composable("settings/integrations/matrix") {
MatrixSettings(navController)
}

composable("settings/integrations/lastfm") {
LastFMSettings(navController)
}
Expand Down
Loading