-
Notifications
You must be signed in to change notification settings - Fork 185
feat: Single audio tracking #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Conversation
elpafras
commented
Oct 12, 2025
- add function to play audio with highlighting verse
- add function to play audio with highlighting verse
|
/gemini review |
|
@gemini-code-assist review |
There was a problem hiding this 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 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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" | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 thebook.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
whenstatement for special book name mappings could also be moved to a more centralized location, likeBookAbbrManager.
| 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") | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| fun updateHighlight(verseNumber: Int, color: Int) { | ||
| Log.d(TAG, "updateHighlight - verseNumber1: $verseNumber, color: $color") | ||
| highlightedVerses[verseNumber] = color | ||
| notifyDataSetChanged() | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)
}
}| 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) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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() | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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))
}
}There was a problem hiding this 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.
| 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) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| fun hideAudioBarIfVisible() { | ||
| if (isAudioVisible) { | ||
| toggleAudioBar() | ||
| isAudioVisible = false | ||
| if (exoplayerController0.state == MediaController.State.playing) { | ||
| exoplayerController0.playOrPause(false) | ||
| } | ||
| timingUtil0.resetHighlight() | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function has a couple of issues:
- It redundantly sets
isAudioVisible = falseaftertoggleAudioBar()has already flipped its value fromtruetofalse. - It only stops
exoplayerController0and resetstimingUtil0. In dual audio mode,exoplayerController1will 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()
}
}| 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) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)| timingUtil0.startHighlightingVerses() | ||
| timingUtil0.highlightVerse(1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)
}| fun updateHighlight(verseNumber: Int, color: Int) { | ||
| Log.d(TAG, "updateHighlight - verseNumber1: $verseNumber, color: $color") | ||
| highlightedVerses[verseNumber] = color | ||
| notifyDataSetChanged() | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
}
}|
/gemini review |
There was a problem hiding this 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.
| displayChapter = { book, chapter -> | ||
| display(chapter, 1) | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)
},There was a problem hiding this comment.
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.
Alkitab/src/main/java/yuku/alkitab/base/util/AudioPlaybackManager.kt
Outdated
Show resolved
Hide resolved
Alkitab/src/main/java/yuku/alkitab/base/util/AudioPlaybackManager.kt
Outdated
Show resolved
Hide resolved
Alkitab/src/main/java/yuku/alkitab/base/util/BibleMediaManager.kt
Outdated
Show resolved
Hide resolved
Alkitab/src/main/java/yuku/alkitab/base/util/BibleMediaManager.kt
Outdated
Show resolved
Hide resolved
| AppLog.d(TAG, "[$id] loadTimingFile called - bookName: $bookName, chapter: $chapter, version: $version") | ||
|
|
||
| timingList = emptyList() | ||
| val modifiedVersion = if (version == "TB") "tbsuara" else version |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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
Alkitab/src/main/java/yuku/alkitab/base/verses/VersesControllerImpl.kt
Outdated
Show resolved
Hide resolved
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
|
/gemini review |
There was a problem hiding this 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| val version1 = activeSplit1?.version?.shortName ?: version0 | |
| val version1 = activeSplit1?.version?.shortName |
| 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) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| Log.d("IsiActivity", "Initial audio_controls color = $color (isNight=$isNight)") | |
| AppLog.d(TAG, "Initial audio_controls color = $color (isNight=$isNight)") |
| 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() | ||
| } | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| 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") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For consistency with the rest of the project, please use the custom AppLog wrapper for logging instead of android.util.Log.
| Log.d(TAG, "onBindViewHolder - position: $position, verse: $index, highlightColor: $highlightColor") | |
| AppLog.d(TAG, "onBindViewHolder - position: $position, verse: $index, highlightColor: $highlightColor") |