Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions feature/autoquran/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ dependencies {
implementation project(":common:ui:core")
implementation project(":common:pages")
implementation project(':common:upgrade')

testImplementation libs.junit
testImplementation libs.truth
testImplementation libs.robolectric
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,29 @@ class BrowsableSurahBuilder @Inject constructor(
private val pageProvider: PageProvider,
private val audioExtensionDecider: AudioExtensionDecider,
private val qariArtworkProvider: QariArtworkProvider,
private val recentQariManager: RecentQariManager,
) {

private val qariMediaItem: MediaItem by lazy {
MediaItem.Builder()
.setMediaId(QARI_ID)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Qaris")
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_MIXED)
.setIsPlayable(false)
.build()
)
.build()
}

private val recentMediaItem: MediaItem by lazy {
MediaItem.Builder()
.setMediaId(RECENT_ID)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle("Recently Played")
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_MIXED)
.setIsPlayable(false)
Expand All @@ -39,7 +55,13 @@ class BrowsableSurahBuilder @Inject constructor(
suspend fun children(parentId: String): ImmutableList<MediaItem> {
return withContext(Dispatchers.IO) {
if (parentId == ROOT_ID) {
ImmutableList.of(qariMediaItem)
if (recentQariManager.getRecentQaris().isNotEmpty()) {
ImmutableList.of(recentMediaItem, qariMediaItem)
} else {
ImmutableList.of(qariMediaItem)
}
} else if (parentId == RECENT_ID) {
recentChildren()
} else if (parentId == QARI_ID) {
val items = pageProvider.getQaris()
.filter { it.isGapless }
Expand Down Expand Up @@ -98,6 +120,25 @@ class BrowsableSurahBuilder @Inject constructor(
}
}

/**
* Return playable [MediaItem]s for the most recently played qaris.
* Each item represents the last sura played for that qari.
*/
private fun recentChildren(): ImmutableList<MediaItem> {
val recents = recentQariManager.getRecentQaris()
if (recents.isEmpty()) return ImmutableList.of()
val qaris = pageProvider.getQaris()
val items = recents.mapNotNull { recent ->
val qari = qaris.firstOrNull { it.id == recent.qariId }
if (qari != null && recent.lastSura in 1..114) {
makeSuraMediaItem(qari, recent.lastSura, showQariSubtitle = true)
} else {
null
}
}
return ImmutableList.copyOf(items)
}

/**
* Given the id of a [Qari], return all the [MediaItem]s for that qari.
* Typically, this is a list of 114 [MediaItem]s, one for each sura.
Expand Down Expand Up @@ -136,7 +177,11 @@ class BrowsableSurahBuilder @Inject constructor(
/**
* Make a [MediaItem] representing a sura for a [Qari]
*/
private fun makeSuraMediaItem(qari: Qari, sura: Int): MediaItem {
private fun makeSuraMediaItem(
qari: Qari,
sura: Int,
showQariSubtitle: Boolean = false
): MediaItem {
val suraName = getSuraName(appContext, sura, wantPrefix = true, wantTranslation = false)
val extension = audioExtensionDecider.audioExtensionForQari(qari)
val (baseUrl, mimeType) = if (extension == "opus" && qari.opusUrl != null) {
Expand All @@ -145,6 +190,7 @@ class BrowsableSurahBuilder @Inject constructor(
qari.url to MimeTypes.AUDIO_MPEG
}
val artworkUri = qariArtworkProvider.suraArtworkUriFor(qari, sura)
val qariName = appContext.getString(qari.nameResource)

return MediaItem.Builder()
.setMediaId("sura_${sura}_${qari.id}")
Expand All @@ -156,8 +202,11 @@ class BrowsableSurahBuilder @Inject constructor(
.setDisplayTitle(suraName)
.setTrackNumber(sura)
.setTotalTrackCount(114)
.setArtist(appContext.getString(qari.nameResource))
.apply { setArtworkUri(artworkUri) }
.setArtist(qariName)
.apply {
if (showQariSubtitle) setSubtitle(qariName)
setArtworkUri(artworkUri)
}
.build()
)
.setMimeType(mimeType)
Expand All @@ -168,5 +217,6 @@ class BrowsableSurahBuilder @Inject constructor(
companion object {
const val ROOT_ID = "__ROOT__"
const val QARI_ID = "__QARI__"
const val RECENT_ID = "__RECENT__"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.quran.labs.feature.autoquran.common

data class RecentQari(
val qariId: Int,
val lastSura: Int,
val timestamp: Long
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.quran.labs.feature.autoquran.common

import android.content.Context
import android.content.SharedPreferences
import com.quran.mobile.di.qualifier.ApplicationContext
import dev.zacsweers.metro.Inject
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber

class RecentQariManager @Inject constructor(
@ApplicationContext appContext: Context,
) {

private val prefs: SharedPreferences =
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

fun getRecentQaris(): List<RecentQari> {
val json = prefs.getString(KEY_RECENT_QARIS, null) ?: return emptyList()
return try {
val array = JSONArray(json)
(0 until array.length()).map { i ->
val obj = array.getJSONObject(i)
RecentQari(
qariId = obj.getInt(FIELD_QARI_ID),
lastSura = obj.getInt(FIELD_LAST_SURA),
timestamp = obj.getLong(FIELD_TIMESTAMP)
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to parse recent qaris")
emptyList()
}
}

fun recordQari(qariId: Int, sura: Int) {
val current = getRecentQaris().toMutableList()
current.removeAll { it.qariId == qariId && it.lastSura == sura }
current.add(0, RecentQari(qariId, sura, System.currentTimeMillis()))
val trimmed = current.take(MAX_RECENT)
val array = JSONArray()
for (entry in trimmed) {
val obj = JSONObject()
obj.put(FIELD_QARI_ID, entry.qariId)
obj.put(FIELD_LAST_SURA, entry.lastSura)
obj.put(FIELD_TIMESTAMP, entry.timestamp)
array.put(obj)
}
prefs.edit().putString(KEY_RECENT_QARIS, array.toString()).apply()
}

companion object {
private const val PREFS_NAME = "autoquran_recent"
private const val KEY_RECENT_QARIS = "recent_qaris"
private const val FIELD_QARI_ID = "qari_id"
private const val FIELD_LAST_SURA = "last_sura"
private const val FIELD_TIMESTAMP = "timestamp"
private const val MAX_RECENT = 5
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ import androidx.media3.session.MediaConstants
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.SessionError
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import com.quran.labs.feature.autoquran.common.BrowsableSurahBuilder
import com.quran.labs.feature.autoquran.common.RecentQariManager
import com.quran.labs.feature.autoquran.di.QuranAutoInjector
import com.quran.mobile.di.QuranApplicationComponentProvider
import dev.zacsweers.metro.Inject
Expand All @@ -32,10 +32,13 @@ import kotlinx.coroutines.launch
import timber.log.Timber

@OptIn(UnstableApi::class)
class QuranBrowsableAudioPlaybackService : MediaSessionService() {
class QuranBrowsableAudioPlaybackService : MediaLibraryService() {
@Inject
lateinit var surahBuilder: BrowsableSurahBuilder

@Inject
lateinit var recentQariManager: RecentQariManager

private var mediaSession: MediaLibrarySession? = null

private val playerListener = PlayerEventListener()
Expand Down Expand Up @@ -65,11 +68,23 @@ class QuranBrowsableAudioPlaybackService : MediaSessionService() {
.build()
}

private val recentRootMediaItem: MediaItem by lazy {
MediaItem.Builder()
.setMediaId(BrowsableSurahBuilder.RECENT_ID)
.setMediaMetadata(
MediaMetadata.Builder()
.setIsBrowsable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_MIXED)
.setIsPlayable(false)
.build()
)
.build()
}

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

override fun onCreate() {
super.onCreate()
Timber.d("onCreate()")
val injector = (application as? QuranApplicationComponentProvider)
?.provideQuranApplicationComponent() as? QuranAutoInjector
if (injector == null) {
Expand Down Expand Up @@ -100,18 +115,55 @@ class QuranBrowsableAudioPlaybackService : MediaSessionService() {
super.onDestroy()
}

override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? =
mediaSession

private class PlayerEventListener : Player.Listener
private inner class PlayerEventListener : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) return
val mediaId = mediaItem?.mediaId ?: return
if (!mediaId.startsWith("sura_")) return
val parts = mediaId.split("_")
if (parts.size != 3) return
val sura = parts[1].toIntOrNull() ?: return
val qariId = parts[2].toIntOrNull() ?: return
if (::recentQariManager.isInitialized) {
recentQariManager.recordQari(qariId, sura)
val recentCount = recentQariManager.getRecentQaris().size
mediaSession?.notifyChildrenChanged(
BrowsableSurahBuilder.RECENT_ID, recentCount, null
)
mediaSession?.notifyChildrenChanged(
BrowsableSurahBuilder.ROOT_ID, recentCount + 1, null

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use correct root child count in change notifications

When a new recent item is recorded, this notifies ROOT_ID with recentCount + 1, but the root node only ever exposes one child (Qaris) or two children (Recently Played + Qaris). After the second recent entry, browsers are told there are 3+ root children that do not exist, which can lead Android Auto clients to request non-existent items and show inconsistent/empty UI. The root notification count should be fixed to the actual root size (typically 2 when recents exist, otherwise 1, or ITEM_COUNT_UNKNOWN).

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

)
}
}
}

private inner class QuranServiceCallback : MediaLibrarySession.Callback {

override fun onSubscribe(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
parentId: String,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<Void>> {
return Futures.immediateFuture(LibraryResult.ofVoid())
}

override fun onGetLibraryRoot(
session: MediaLibrarySession,
browser: MediaSession.ControllerInfo,
params: MediaLibraryService.LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> {
if (params?.isRecent == true) {
val recentParams = MediaLibraryService.LibraryParams.Builder()
.setRecent(true)
.build()
return Futures.immediateFuture(
LibraryResult.ofItem(recentRootMediaItem, recentParams)
)
}
val rootExtras = Bundle().apply {
putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM)
putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
Expand Down Expand Up @@ -261,4 +313,5 @@ class QuranBrowsableAudioPlaybackService : MediaSessionService() {
return settable
}
}

}
Loading