Skip to content

Conversation

@elpafras
Copy link

  • add function to play audio with highlighting verse

- add function to play audio with highlighting verse
@yukuku
Copy link
Owner

yukuku commented Oct 14, 2025

/gemini review

@yukuku yukuku closed this Oct 14, 2025
@yukuku yukuku reopened this Oct 14, 2025
@yukuku
Copy link
Owner

yukuku commented Oct 14, 2025

@gemini-code-assist review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a significant new feature: audio playback with verse highlighting. The implementation is extensive and covers both single and dual-language playback. While the feature seems well-integrated, there are several areas for improvement in terms of code structure, maintainability, and efficiency. Specifically, the main activity has become quite large, and some of the new logic is tightly coupled, which could make future maintenance difficult. I've provided several suggestions to improve separation of concerns, reduce code duplication, and enhance performance.

private const val INSTANCE_STATE_ari = "ari"

class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener, ExoplayerController.ExoplayerCallback, TimingUtil.HighlightListener, MediaStateListener {

Choose a reason for hiding this comment

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

high

This activity has grown quite large and is taking on many responsibilities, including complex audio playback logic. To improve maintainability and separation of concerns, consider refactoring the audio-related logic (e.g., ExoPlayerController instances, TimingUtil instances, coroutine jobs, and all the audio control functions like playAlternating, navigateVerse, etc.) into a separate AudioPlaybackManager class. The IsiActivity would then own and delegate to this manager, observing its state through a listener or Flow. This would make the activity cleaner and the audio logic more modular and testable.

Comment on lines 1549 to 1574
private fun buildAudioUrl(book: Book, version: String): String {
val bookShortName = when (book.shortName) {
"Hakim-hakim" -> "Hakim"
"1 Raja-raja" -> "1Raja"
"2 Raja-raja" -> "2Raja"
"Kidung Agung" -> "Kidung"
"Kisah Para Rasul" -> "Kisah"
else -> book.shortName
}

val booksList = BookAbbrManager.bookAbbrMap.keys.toList()
val plorpb = if (booksList.indexOf(book.shortName) <= booksList.indexOf("Maleakhi")) "pl" else "pb"

val bookCode = if (plorpb == "pl") {
String.format(Locale.US, "%02d", book.bookId + 1)
} else {
val pbBooks = booksList.subList(booksList.indexOf("Matius"), booksList.size)
String.format(Locale.US, "%02d", pbBooks.indexOf(book.shortName) + 1)
}

val chapter = String.format(Locale.US, if (book.shortName == "Mazmur") "%03d" else "%02d", chapter_1)
val abbr = BookAbbrManager.bookAbbrMap[book.shortName] ?: return ""
val audioVersion = MediaList.AUDIO.find { it.version == version }?.audio1 ?: return ""

return "https://media.sabda.org/alkitab_audio/$audioVersion/$plorpb/mp3/cd/${bookCode}_${bookShortName.lowercase().replace(" ", "")}/${bookCode}_${abbr}${chapter}.mp3"
}

Choose a reason for hiding this comment

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

high

The buildAudioUrl function has a few areas that could be made more robust:

  • The logic to determine if a book is in the Old or New Testament (plorpb) relies on the string name's index in a list. A more reliable method would be to use the book.bookId. Old Testament books typically have IDs from 0-38 and New Testament from 39-65.
  • The URL format is hardcoded. This creates a tight coupling with the external media server. It would be better to move this URL construction logic into a dedicated class (e.g., SabdaMediaUrlBuilder) and define the base URL and path segments as constants.
  • The when statement for special book name mappings could also be moved to a more centralized location, like BookAbbrManager.

Comment on lines 2054 to 2117
private fun ensureAudioReady(onReady: () -> Unit) {
val urls = generateAudioUrl().split(";")
val book = activeSplit0.book.shortName
val chapter = chapter_1
val version0 = activeSplit0.version.shortName
val version1 = activeSplit1?.version?.shortName

AppLog.d(TAG, "ensureAudioReady() called")
AppLog.d(TAG, "Book: $book, Chapter: $chapter, Version0: $version0, Version1: $version1")
AppLog.d(TAG, "Split Version Aktif: ${cSplitVersion.isChecked}")
AppLog.d(TAG, "Generated URLs: $urls")

onReady()

fun prepareController(controller: ExoplayerController, url: String, label: String) {
AppLog.d(TAG, "prepareController() - $label - url: $url - state: ${controller.state}")

if (controller.state in listOf(
MediaController.State.reset,
MediaController.State.reset_media_known_to_exist,
MediaController.State.error
)
) {
controller.apply {
mediaKnownToExist(url)
setAudioUrl(url)
mediaPlayerPrepare(url, false)
playOrPause(false)
}
AppLog.d(TAG, "Audio initialized ($label) with URL: $url")
} else {
AppLog.d(TAG, "Audio ($label) already in state: ${controller.state}, skipping re-init")
}
}

// -- AUDIO --
if (cSplitVersion.isChecked) {
prepareController(exoplayerController0, urls.getOrNull(0).orEmpty(), "split 0")
prepareController(exoplayerController1, urls.getOrNull(1).orEmpty(), "split 1")
} else {
prepareController(exoplayerController0, urls.getOrNull(0).orEmpty(), "single")
}

// -- TIMING --
AppLog.d(TAG, "Memuat timing untuk controller0")
timingUtil0.loadTimingFile(book, chapter.toString(), version0,
onSuccess = {
AppLog.d(TAG, "Timing controller0 berhasil dimuat")
},
onError = {
AppLog.e(TAG, "Timing file gagal dimuat (controller0): $it")
})

if (cSplitVersion.isChecked) {
AppLog.d(TAG, "Memuat timing untuk controller1")
timingUtil1.loadTimingFile(book, chapter.toString(), version1.orEmpty(),
onSuccess = {
AppLog.d(TAG, "Timing controller1 berhasil dimuat")
},
onError = {
AppLog.e(TAG, "Timing file gagal dimuat (controller1): $it")
})
}
}

Choose a reason for hiding this comment

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

high

The function name ensureAudioReady is misleading. It calls the onReady lambda immediately at the beginning, before the audio player is actually prepared or timing files are loaded. The playback is initiated inside onReady, which means it's requested before the player is ready. While ExoPlayer might queue this, it makes the logic hard to follow. The onReady callback should be invoked only after all asynchronous preparation steps (like loading timing files) are complete.

Comment on lines 753 to 757
fun updateHighlight(verseNumber: Int, color: Int) {
Log.d(TAG, "updateHighlight - verseNumber1: $verseNumber, color: $color")
highlightedVerses[verseNumber] = color
notifyDataSetChanged()
}

Choose a reason for hiding this comment

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

high

Calling notifyDataSetChanged() is inefficient as it forces the entire RecyclerView to redraw. This can cause performance issues and flickering, especially with frequent updates like verse highlighting during audio playback. It's much more efficient to update only the specific item that has changed using notifyItemChanged(position).

    fun updateHighlight(verseNumber: Int, color: Int) {
        Log.d(TAG, "updateHighlight - verseNumber1: $verseNumber, color: $color")
        if (color == 0) {
            highlightedVerses.remove(verseNumber)
        } else {
            highlightedVerses[verseNumber] = color
        }
        val position = data.getPositionFromVerse(verseNumber + 1)
        if (position != -1) {
            notifyItemChanged(position)
        }
    }

Comment on lines 304 to 320
fun highlightVerses(selectedVerses: IntArrayList, color: Int) {
AppLog.d(TAG, "highlightVerses called - selectedVerses: $selectedVerses color: $color")
for (verse in selectedVerses.toIntArray()) {
updateVerseHighlightUI(verse, color)
}
}

private fun updateVerseHighlightUI(verseNumber: Int, color: Int) {
AppLog.d(TAG, "Highlighting verse $verseNumber with color $color")

activityRef?.get()?.let { activity ->
listOf(R.id.lsSplitView0, R.id.lsSplitView1).forEach { viewId ->
(activity.findViewById<EmptyableRecyclerView>(viewId)?.adapter as? VersesAdapter)
?.updateHighlight(verseNumber, color)
}
}
}

Choose a reason for hiding this comment

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

high

The ExoplayerController is tightly coupled with the UI implementation in IsiActivity through the updateVerseHighlightUI method, which directly accesses views by ID. This violates separation of concerns and makes the controller less reusable and harder to test. The highlighting logic should be handled by the view layer (IsiActivity) through a listener. You already have a good pattern with ExoplayerCallback and TimingUtil.HighlightListener.

I suggest removing highlightVerses and updateVerseHighlightUI from this controller. IsiActivity.applyHighlight can be simplified to directly call the VersesAdapter's updateHighlight method on the relevant VersesController.

android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:layout_marginTop="40dp"

Choose a reason for hiding this comment

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

high

Using a hardcoded android:layout_marginTop="40dp" on splitRoot to create space for the imageContainer is not a robust layout strategy. It can lead to UI issues on different screen sizes or with different font settings. A better approach would be to use a RelativeLayout as the parent of both imageContainer and splitRoot, and position splitRoot below imageContainer using android:layout_below="@id/imageContainer". This ensures the layout is flexible and adapts correctly.

Comment on lines 1751 to 1869
private fun navigateChapterSingle(isNext: Boolean, toFirstVerse: Boolean = false, toLastVerse: Boolean = false) {
stopAudioForNavigation()
AppLog.d(TAG, "navigateChapterSingle: pause dulu state=${exoplayerController0.state}")

if (isNext) {
if (chapter_1 >= activeSplit0.book.chapter_count) {
val maxBookId = activeSplit0.version.maxBookIdPlusOne
var tryBookId = activeSplit0.book.bookId + 1
while (tryBookId < maxBookId) {
val newBook = activeSplit0.version.getBook(tryBookId)
if (newBook != null) {
activeSplit0 = activeSplit0.copy(book = newBook)
display(1, 1)
updateAudioForNewChapter()
timingUtil0.startHighlightingVerses()
if (toFirstVerse) timingUtil0.highlightVerse(1)
return
}
tryBookId++
}
} else {
display(chapter_1 + 1, 1)
updateAudioForNewChapter()
timingUtil0.startHighlightingVerses()
if (toFirstVerse) timingUtil0.highlightVerse(1)
}
} else {
if (chapter_1 == 1) {
var tryBookId = activeSplit0.book.bookId - 1
while (tryBookId >= 0) {
val newBook = activeSplit0.version.getBook(tryBookId)
if (newBook != null) {
activeSplit0 = activeSplit0.copy(book = newBook)
display(newBook.chapter_count, 1)
updateAudioForNewChapter()
timingUtil0.startHighlightingVerses()
if (toLastVerse) {
val lastVerse = timingUtil0.timingList.lastOrNull()?.third ?: 1
timingUtil0.highlightVerse(lastVerse)
}
return
}
tryBookId--
}
} else {
display(chapter_1 - 1, 1)
updateAudioForNewChapter()
timingUtil0.startHighlightingVerses()
if (toLastVerse) {
val lastVerse = timingUtil0.timingList.lastOrNull()?.third ?: 1
timingUtil0.highlightVerse(lastVerse)
}
}
}
}

private fun navigateChapterDual(isNext: Boolean, toFirstVerse: Boolean = false, toLastVerse: Boolean = false) {
stopAudioForNavigation()

if (isNext) {
if (chapter_1 >= activeSplit0.book.chapter_count) {
val maxBookId = activeSplit0.version.maxBookIdPlusOne
var tryBookId = activeSplit0.book.bookId + 1
while (tryBookId < maxBookId) {
val newBook = activeSplit0.version.getBook(tryBookId)
if (newBook != null) {
activeSplit0 = activeSplit0.copy(book = newBook)
display(1, 1)
updateAudioForNewChapter()
if (toFirstVerse) {
timingUtil0.highlightVerse(1)
timingUtil1.highlightVerse(1)
}
return
}
tryBookId++
}
} else {
display(chapter_1 + 1, 1)
updateAudioForNewChapter()
if (toFirstVerse) {
timingUtil0.highlightVerse(1)
timingUtil1.highlightVerse(1)
}
}
} else {
if (chapter_1 == 1) {
var tryBookId = activeSplit0.book.bookId - 1
while (tryBookId >= 0) {
val newBook = activeSplit0.version.getBook(tryBookId)
if (newBook != null) {
activeSplit0 = activeSplit0.copy(book = newBook)
display(newBook.chapter_count, 1)
updateAudioForNewChapter()
if (toLastVerse) {
val lastVerse = minOf(
timingUtil0.timingList.lastOrNull()?.third ?: 1,
timingUtil1.timingList.lastOrNull()?.third ?: 1
)
timingUtil0.highlightVerse(lastVerse)
timingUtil1.highlightVerse(lastVerse)
}
return
}
tryBookId--
}
} else {
display(chapter_1 - 1, 1)
updateAudioForNewChapter()
if (toLastVerse) {
val lastVerse = minOf(
timingUtil0.timingList.lastOrNull()?.third ?: 1,
timingUtil1.timingList.lastOrNull()?.third ?: 1
)
timingUtil0.highlightVerse(lastVerse)
timingUtil1.highlightVerse(lastVerse)
}
}
}

Choose a reason for hiding this comment

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

medium

The functions navigateChapterSingle and navigateChapterDual contain a lot of duplicated code for navigating to the next or previous chapter/book. This logic could be extracted into a common helper function to reduce redundancy and improve maintainability. For example, you could have a function that determines the next (bookId, chapter_1) pair, and then the single/dual functions would just handle the UI/audio updates specific to their mode.

Comment on lines 1927 to 1968
private fun showSpeedMenu() {
val popupMenu = PopupMenu(this, buttonSpeed)
val menu = popupMenu.menu

menu.add(0, 1, 0, "0.5")
menu.add(0, 2, 0, "0.8")
menu.add(0, 3, 0, "Normal")
menu.add(0, 4, 0, "1.1")
menu.add(0, 5, 0, "1.25")
menu.add(0, 6, 0, "1.5")
menu.add(0, 7, 0, "1.75")
menu.add(0, 8, 0, "2.0")

menu.setGroupCheckable(0, true, true)

when (exoplayerController0.getPlaybackSpeed()) {
0.5f -> menu.findItem(1)?.isChecked = true
0.8f -> menu.findItem(2)?.isChecked = true
1.0f -> menu.findItem(3)?.isChecked = true
1.1f -> menu.findItem(4)?.isChecked = true
1.25f -> menu.findItem(5)?.isChecked = true
1.5f -> menu.findItem(6)?.isChecked = true
1.75f -> menu.findItem(7)?.isChecked = true
2.0f -> menu.findItem(8)?.isChecked = true
}

popupMenu.setOnMenuItemClickListener { item ->
when (item.itemId) {
1 -> exoplayerController0.setPlaybackSpeed(0.5f)
2 -> exoplayerController0.setPlaybackSpeed(0.8f)
3 -> exoplayerController0.setPlaybackSpeed(1.0f)
4 -> exoplayerController0.setPlaybackSpeed(1.1f)
5 -> exoplayerController0.setPlaybackSpeed(1.25f)
6 -> exoplayerController0.setPlaybackSpeed(1.5f)
7 -> exoplayerController0.setPlaybackSpeed(1.75f)
8 -> exoplayerController0.setPlaybackSpeed(2.0f)
}
true
}

popupMenu.show()
}

Choose a reason for hiding this comment

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

medium

The showSpeedMenu function uses magic numbers for menu item IDs and has repetitive when blocks. This can be simplified and made more maintainable. Consider defining the speed options in a data structure (e.g., a Map<Int, Float>) and creating the menu items in a loop. This would also simplify the setOnMenuItemClickListener. Alternatively, defining the menu in an XML resource (res/menu/speed_menu.xml) would be even better, following standard Android practices.

Comment on lines 20 to 26
fun fromJsonArray(jsonText: String): List<Triple<Long, Long, Int>> {
val timestampsArray = JSONObject(jsonText).getJSONArray("timestamps")
return List(timestampsArray.length()) { i ->
val timing = fromJson(timestampsArray.getJSONObject(i))
Triple(timing.startTime, timing.endTime, timing.verseNumber)
}
}

Choose a reason for hiding this comment

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

medium

The fromJsonArray function currently returns a List<Triple<Long, Long, Int>>. Using a Triple makes the code harder to read and maintain, as the meaning of first, second, and third is not immediately obvious. Since you already have the MTiming data class, it would be much clearer to return a List<MTiming>.

This will require updating the type of timingList in TimingUtil and other places from List<Triple<Long, Long, Int>> to List<MTiming>.

        fun fromJsonArray(jsonText: String): List<MTiming> {
            val timestampsArray = JSONObject(jsonText).getJSONArray("timestamps")
            return List(timestampsArray.length()) { i ->
                fromJson(timestampsArray.getJSONObject(i))
            }
        }

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a great new feature for audio playback with verse highlighting, supporting both single and dual-language modes. The use of coroutines for handling asynchronous operations is a good choice. However, there are several areas that need improvement, particularly regarding coroutine scope management which can lead to memory leaks, some fragile logic for URL building, and a layout issue that uses a magic number. I've left specific comments with suggestions on how to address these points. Overall, a solid feature addition with some important refinements needed.

Comment on lines 1559 to 1567
val booksList = BookAbbrManager.bookAbbrMap.keys.toList()
val plorpb = if (booksList.indexOf(book.shortName) <= booksList.indexOf("Maleakhi")) "pl" else "pb"

val bookCode = if (plorpb == "pl") {
String.format(Locale.US, "%02d", book.bookId + 1)
} else {
val pbBooks = booksList.subList(booksList.indexOf("Matius"), booksList.size)
String.format(Locale.US, "%02d", pbBooks.indexOf(book.shortName) + 1)
}

Choose a reason for hiding this comment

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

high

The logic for determining the testament (plorpb) and bookCode is fragile and inefficient. It relies on creating a list from map keys and finding indices of strings, which is not robust as map key order is not always guaranteed. This also creates a new list on every call, which is inefficient.

A better approach is to use book.bookId, which is a stable integer. Old Testament books have bookId < 39.

        val isOldTestament = book.bookId < 39
        val plorpb = if (isOldTestament) "pl" else "pb"

        val bookCode = if (isOldTestament) {
            String.format(Locale.US, "%02d", book.bookId + 1)
        } else {
            String.format(Locale.US, "%02d", book.bookId - 39 + 1)
        }

) {
// cancel job lama kalau ada
dualJob?.cancel()
dualJob = CoroutineScope(Dispatchers.Main).launch {

Choose a reason for hiding this comment

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

high

Creating a new CoroutineScope with CoroutineScope(Dispatchers.Main) can lead to memory leaks if the Activity is destroyed but the coroutine is still running. It's better to use a lifecycle-aware scope. Since this is an Activity, you can use lifecycleScope.launch which will automatically cancel the coroutine when the activity is destroyed.

        dualJob = lifecycleScope.launch {

val url = "https://karaoke.sabda.org/api/timming.php?book=$bookName&chapter=$chapter&version=$modifiedVersion"
AppLog.d(TAG, "[$id] loadTimingFile called - url: $url")

CoroutineScope(Dispatchers.IO).launch {

Choose a reason for hiding this comment

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

high

Creating a new CoroutineScope with CoroutineScope(Dispatchers.IO) can lead to memory leaks if the owner (IsiActivity) is destroyed but the coroutine is still running. This class should accept a CoroutineScope in its constructor and use that to launch coroutines. IsiActivity can pass its lifecycleScope.

Example:

// In TimingUtil class
class TimingUtil(
    private val scope: CoroutineScope,
    // ... other params
) {
    // ...
    fun loadTimingFile(...) {
        scope.launch(Dispatchers.IO) { /* ... */ }
    }
    // ...
}

// In IsiActivity
timingUtil0 = TimingUtil(lifecycleScope, exoplayerController0, this, id = "controller0")

callback?.onPlayerStateChanged(true)
}

CoroutineScope(Dispatchers.Main).launch {

Choose a reason for hiding this comment

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

high

Creating a new CoroutineScope with CoroutineScope(Dispatchers.Main) can lead to memory leaks if the Activity is destroyed but the coroutine is still running. It's better to use a lifecycle-aware scope. Since this controller is used within an Activity, you could pass the Activity's lifecycleScope to this controller and use it to launch coroutines. This will ensure that coroutines are automatically cancelled when the Activity is destroyed.

android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:layout_marginTop="40dp"

Choose a reason for hiding this comment

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

high

Using a hardcoded android:layout_marginTop="40dp" on splitRoot to make space for imageContainer is a fragile layout practice. If the height of imageContainer changes, this margin will need to be updated manually.

A better approach is to change the parent FrameLayout (@+id/nontoolbar) to a RelativeLayout and position splitRoot below imageContainer using android:layout_below="@id/imageContainer". This will make the layout adjust automatically and more robust.

Comment on lines 1506 to 1515
fun hideAudioBarIfVisible() {
if (isAudioVisible) {
toggleAudioBar()
isAudioVisible = false
if (exoplayerController0.state == MediaController.State.playing) {
exoplayerController0.playOrPause(false)
}
timingUtil0.resetHighlight()
}
}

Choose a reason for hiding this comment

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

medium

This function has a couple of issues:

  1. It redundantly sets isAudioVisible = false after toggleAudioBar() has already flipped its value from true to false.
  2. It only stops exoplayerController0 and resets timingUtil0. In dual audio mode, exoplayerController1 will continue playing, which is likely not the intended behavior when hiding the audio bar.

You can call stopAudioForNavigation() here to correctly stop both players and reset highlights for both single and dual modes.

    fun hideAudioBarIfVisible() {
        if (isAudioVisible) {
            toggleAudioBar()
            stopAudioForNavigation()
        }
    }

Comment on lines 1954 to 1963
when (item.itemId) {
1 -> exoplayerController0.setPlaybackSpeed(0.5f)
2 -> exoplayerController0.setPlaybackSpeed(0.8f)
3 -> exoplayerController0.setPlaybackSpeed(1.0f)
4 -> exoplayerController0.setPlaybackSpeed(1.1f)
5 -> exoplayerController0.setPlaybackSpeed(1.25f)
6 -> exoplayerController0.setPlaybackSpeed(1.5f)
7 -> exoplayerController0.setPlaybackSpeed(1.75f)
8 -> exoplayerController0.setPlaybackSpeed(2.0f)
}

Choose a reason for hiding this comment

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

medium

The playback speed is only set for exoplayerController0. When in dual audio mode, exoplayerController1 will continue to play at its own speed, which will be out of sync. The speed should be set for both controllers to ensure they play at the same rate.

            val speed = when (item.itemId) {
                1 -> 0.5f
                2 -> 0.8f
                3 -> 1.0f
                4 -> 1.1f
                5 -> 1.25f
                6 -> 1.5f
                7 -> 1.75f
                8 -> 2.0f
                else -> return@setOnMenuItemClickListener false
            }
            exoplayerController0.setPlaybackSpeed(speed)
            exoplayerController1.setPlaybackSpeed(speed)

Comment on lines 2135 to 2136
timingUtil0.startHighlightingVerses()
timingUtil0.highlightVerse(1)

Choose a reason for hiding this comment

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

medium

When the audio ends, startHighlightingVerses() and highlightVerse(1) are called on timingUtil0. In dual audio mode, timingUtil1 should also be handled to keep things in sync when repeating or moving to the next chapter.

        timingUtil0.startHighlightingVerses()
        timingUtil0.highlightVerse(1)
        if (cSplitVersion.isChecked) {
            timingUtil1.highlightVerse(1)
        }

Comment on lines 753 to 757
fun updateHighlight(verseNumber: Int, color: Int) {
Log.d(TAG, "updateHighlight - verseNumber1: $verseNumber, color: $color")
highlightedVerses[verseNumber] = color
notifyDataSetChanged()
}

Choose a reason for hiding this comment

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

medium

notifyDataSetChanged() is inefficient as it redraws the entire list. It's better to use notifyItemChanged() to update only the specific verse view that needs to be re-rendered. You'll need to find the position of the item from the verseNumber.

    fun updateHighlight(verseNumber: Int, color: Int) {
        Log.d(TAG, "updateHighlight - verseNumber1: $verseNumber, color: $color")
        highlightedVerses[verseNumber] = color
        val position = data.getPositionIgnoringPericopeFromVerse(verseNumber + 1)
        if (position != -1) {
            notifyItemChanged(position)
        } else {
            notifyDataSetChanged() // Fallback
        }
    }

@yukuku
Copy link
Owner

yukuku commented Nov 10, 2025

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a significant new feature for audio playback with verse highlighting. The implementation is quite extensive, involving new managers, controllers, and UI components. Overall, the structure is well-thought-out, especially the use of coroutines for handling playback logic. However, I've identified a few critical correctness issues, particularly in state management when navigating across books, and some areas for improvement in terms of maintainability and robustness. My review comments focus on fixing these bugs and improving the code's clarity and long-term health.

Comment on lines 1197 to 1199
displayChapter = { book, chapter ->
display(chapter, 1)
},

Choose a reason for hiding this comment

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

high

There's a potential bug in the displayChapter lambda. It doesn't update activeSplit0.book with the new book when navigating chapters across different books. This will cause display() to show the new chapter in the old book. You should update activeSplit0 before calling display().

            displayChapter = { book, chapter ->
                activeSplit0 = activeSplit0.copy(book = book)
                display(chapter, 1)
            },

Copy link
Author

Choose a reason for hiding this comment

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

For this issue, I changed the book to _ because I don't really need to set the book name yet.

AppLog.d(TAG, "[$id] loadTimingFile called - bookName: $bookName, chapter: $chapter, version: $version")

timingList = emptyList()
val modifiedVersion = if (version == "TB") "tbsuara" else version

Choose a reason for hiding this comment

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

medium

There is hardcoded logic to map the version "TB" to "tbsuara". This kind of version-specific logic is scattered across the codebase. It would be better to centralize this logic, perhaps in MediaList or a dedicated utility, to improve maintainability and avoid inconsistencies.

Copy link
Author

Choose a reason for hiding this comment

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

updated in changes to be sent soon

improvements related to the repeat process, behavior when the audio player is running, chapter switching (next/prev), and optimizations related to comments (reviews) from Gemini
@yukuku
Copy link
Owner

yukuku commented Nov 14, 2025

/gemini review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a significant new feature for audio playback with verse highlighting. The implementation is quite comprehensive, covering single and dual (split) audio modes, playback controls (play/pause, navigation, speed, repeat), and integration with the existing UI. The use of coroutines for handling asynchronous operations is well done.

I've identified a few potential issues, mainly around edge cases and error handling, such as what happens when an audio URL or timing file can't be loaded. There are also some minor inconsistencies in logging. Addressing these points will make the feature more robust and maintainable.

Overall, this is a great addition to the app.


if (isSplit) {
// --- Dual Audio ---
val version1 = activeSplit1?.version?.shortName ?: version0

Choose a reason for hiding this comment

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

high

In split mode, if activeSplit1 is null or its version has no short name, version1 falls back to version0. This will cause url1 to be the same as url0, and both ExoPlayer instances will be set up to play the same audio file. This is likely not the intended behavior and wastes resources.

It would be better to make version1 nullable and only set up the second player if version1 is not null.

Suggested change
val version1 = activeSplit1?.version?.shortName ?: version0
val version1 = activeSplit1?.version?.shortName

Comment on lines +1574 to +1588
val url0 = buildAudioUrl(book, chapter_1,version0)
val url1 = buildAudioUrl(book, chapter_1, version1)

AppLog.d(TAG, "Dual mode URLs → version0=$url0 | version1=$url1")

// Reset & siapkan audio controller 0
timingUtil0.clearTimingList()
timingUtil0.resetHighlight()
with(exoplayerController0) {
stopSegment()
mediaKnownToExist(url0)
setAudioUrl(url0)
state = MediaController.State.reset_media_known_to_exist
playOrPause(false)
}

Choose a reason for hiding this comment

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

high

The buildAudioUrl function can return an empty string if an audio version is not found. However, the returned URL is used in mediaKnownToExist and setAudioUrl without checking if it's empty. This could cause ExoPlayer to throw an exception when trying to prepare an empty URL. You should add a check to ensure the URL is not empty before using it to set up the player.

This applies to url0, url1 in dual mode, and the url in single audio mode (line 1613).

val audioControls = audioBar.findViewById<RelativeLayout>(R.id.audio_controls)
val color = if (isNight) "#121212".toColorInt() else "#455A64".toColorInt()
audioControls?.setBackgroundColor(color)
Log.d("IsiActivity", "Initial audio_controls color = $color (isNight=$isNight)")

Choose a reason for hiding this comment

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

medium

For consistency with the rest of the project which uses a custom AppLog wrapper, please use AppLog.d instead of android.util.Log.d. This also applies to other Log.d calls on lines 1791 and 3304.

Suggested change
Log.d("IsiActivity", "Initial audio_controls color = $color (isNight=$isNight)")
AppLog.d(TAG, "Initial audio_controls color = $color (isNight=$isNight)")

Comment on lines +1696 to +1721
onError = {
AppLog.e(TAG, "Timing file gagal dimuat (controller0): $it")
timing0Loaded = true // Tetap true agar tidak deadlock
tryCallOnReady()
}
)

// timing untuk controller1 (jika split aktif)
if (cSplitVersion.isChecked) {
val v1 = version1
if (!v1.isNullOrEmpty()) {
timingUtil1.loadTimingFile(
book,
chapter.toString(),
v1,
onSuccess = {
AppLog.d(TAG, "Timing controller1 berhasil dimuat")
timing1Loaded = true
tryCallOnReady()
},
onError = {
AppLog.e(TAG, "Timing file gagal dimuat (controller1): $it")
timing1Loaded = true
tryCallOnReady()
}
)

Choose a reason for hiding this comment

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

medium

When loading the timing file fails, the error is logged, but the user is not notified. The playback proceeds without the verse highlighting feature. This is a reasonable fallback, but it's a silent failure. It would be a better user experience to provide some feedback, like a Toast message, indicating that verse highlighting is unavailable for the current chapter due to a loading error.

// Pastikan mulai dari ayat pertama
audioPlaybackManager.navigateChapter(true, toFirstVerse = true)
lifecycleScope.launch {
delay(800)

Choose a reason for hiding this comment

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

medium

Using a hardcoded delay(800) can be unreliable as it assumes the UI update will complete within 800ms. This could lead to race conditions on slower devices or under heavy load. Consider using a more robust synchronization mechanism, like a callback or a View.post() on a relevant view after navigateChapter has updated the UI, to ensure the UI is ready before proceeding with highlighting and playing the verse.

}
}
}
} No newline at end of file

Choose a reason for hiding this comment

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

medium

It's a common convention to end files with a newline character. This file, and several other new files in this PR (AudioPlaybackManager.kt, BibleMediaManager.kt, MediaList.kt, NetworkUtil.kt, TimingUtil.kt), are missing it.

holder.bind(data, ui, listeners, attention, isChecked(position), { toggleChecked(it) }, index)
val highlightColor = highlightedVerses[index] ?: 0

Log.d(TAG, "onBindViewHolder - position: $position, verse: $index, highlightColor: $highlightColor")

Choose a reason for hiding this comment

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

medium

For consistency with the rest of the project, please use the custom AppLog wrapper for logging instead of android.util.Log.

Suggested change
Log.d(TAG, "onBindViewHolder - position: $position, verse: $index, highlightColor: $highlightColor")
AppLog.d(TAG, "onBindViewHolder - position: $position, verse: $index, highlightColor: $highlightColor")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants