diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index 3cd2840ae..db53c77b3 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -11,6 +11,8 @@
-
+
+
+
\ No newline at end of file
diff --git a/Alkitab/build.gradle b/Alkitab/build.gradle
index 3e735531d..7ab0de982 100644
--- a/Alkitab/build.gradle
+++ b/Alkitab/build.gradle
@@ -101,7 +101,7 @@ dependencies {
implementation "androidx.preference:preference-ktx:$androidxPreferenceVersion"
implementation "androidx.recyclerview:recyclerview:$androidxRecyclerviewVersion"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:$androidxSwiperefreshlayoutVersion"
- implementation "androidx.work:work-runtime-ktx:2.10.3"
+ implementation "androidx.work:work-runtime-ktx:2.10.5"
// Google
implementation "androidx.media3:media3-exoplayer:$androidxMedia3Version"
@@ -115,7 +115,7 @@ dependencies {
debugImplementation "com.squareup.leakcanary:leakcanary-android:$leakcanaryVersion"
// Firebase
- implementation platform('com.google.firebase:firebase-bom:33.7.0')
+ implementation platform('com.google.firebase:firebase-bom:34.4.0')
implementation "com.google.firebase:firebase-messaging"
implementation "com.google.firebase:firebase-crashlytics"
diff --git a/Alkitab/src/main/java/yuku/alkitab/base/IsiActivity.kt b/Alkitab/src/main/java/yuku/alkitab/base/IsiActivity.kt
index 2bd9c0026..9be0ef544 100644
--- a/Alkitab/src/main/java/yuku/alkitab/base/IsiActivity.kt
+++ b/Alkitab/src/main/java/yuku/alkitab/base/IsiActivity.kt
@@ -18,6 +18,7 @@ import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.URLSpan
+import android.util.Log
import android.view.Gravity
import android.view.KeyEvent
import android.view.Menu
@@ -28,7 +29,9 @@ import android.view.ViewTreeObserver
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageButton
+import android.widget.ImageView
import android.widget.LinearLayout
+import android.widget.RelativeLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.view.ActionMode
@@ -38,6 +41,7 @@ import androidx.core.app.ActivityCompat
import androidx.core.app.ShareCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.ColorUtils
+import androidx.core.graphics.toColorInt
import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
import androidx.core.text.buildSpannedString
@@ -47,6 +51,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.drawerlayout.widget.DrawerLayout
+import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.materialdialogs.MaterialDialog
import com.google.android.material.snackbar.Snackbar
@@ -55,6 +60,9 @@ import java.util.Date
import java.util.GregorianCalendar
import java.util.Locale
import kotlin.math.roundToLong
+import kotlin.properties.Delegates
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import me.toptas.fancyshowcase.FancyShowCaseView
import me.toptas.fancyshowcase.listener.DismissListener
import yuku.afw.storage.Preferences
@@ -77,7 +85,9 @@ import yuku.alkitab.base.settings.SettingsActivity
import yuku.alkitab.base.storage.Prefkey
import yuku.alkitab.base.util.AppLog
import yuku.alkitab.base.util.Appearances
+import yuku.alkitab.base.util.AudioPlaybackManager
import yuku.alkitab.base.util.BackForwardListController
+import yuku.alkitab.base.util.BibleMediaManager.buildAudioUrl
import yuku.alkitab.base.util.ClipboardUtil
import yuku.alkitab.base.util.CurrentReading
import yuku.alkitab.base.util.ExtensionManager
@@ -86,14 +96,18 @@ import yuku.alkitab.base.util.History
import yuku.alkitab.base.util.InstallationUtil
import yuku.alkitab.base.util.Jumper
import yuku.alkitab.base.util.LidToAri
+import yuku.alkitab.base.util.NetworkUtil
import yuku.alkitab.base.util.OtherAppIntegration
import yuku.alkitab.base.util.RequestCodes
import yuku.alkitab.base.util.ShareUrl
import yuku.alkitab.base.util.Sqlitil
import yuku.alkitab.base.util.TargetDecoder
+import yuku.alkitab.base.util.TimingUtil
import yuku.alkitab.base.util.safeQuery
import yuku.alkitab.base.util.toIntArray
+import yuku.alkitab.base.verses.EmptyableRecyclerView
import yuku.alkitab.base.verses.VerseAttributeLoader
+import yuku.alkitab.base.verses.VersesAdapter
import yuku.alkitab.base.verses.VersesController
import yuku.alkitab.base.verses.VersesControllerImpl
import yuku.alkitab.base.verses.VersesDataModel
@@ -124,6 +138,9 @@ import yuku.alkitab.model.PericopeBlock
import yuku.alkitab.model.SingleChapterVerses
import yuku.alkitab.model.Version
import yuku.alkitab.ribka.RibkaReportActivity
+import yuku.alkitab.songs.ExoplayerController
+import yuku.alkitab.songs.MediaController
+import yuku.alkitab.songs.MediaStateListener
import yuku.alkitab.util.Ari
import yuku.alkitab.util.IntArrayList
import yuku.alkitab.versionmanager.VersionsActivity
@@ -133,7 +150,7 @@ private const val TAG = "IsiActivity"
private const val EXTRA_verseUrl = "verseUrl"
private const val INSTANCE_STATE_ari = "ari"
-class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
+class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener, ExoplayerController.ExoplayerCallback, TimingUtil.HighlightListener, MediaStateListener {
var uncheckVersesWhenActionModeDestroyed = true
var needsRestart = false // whether this activity needs to be restarted
@@ -251,6 +268,34 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
private lateinit var bLeft: ImageButton
private lateinit var bRight: ImageButton
private lateinit var bVersion: TextView
+ private lateinit var cSplitVersion: SwitchCompat
+ private lateinit var cNightMode: SwitchCompat
+
+ //audio
+
+ private lateinit var audioPlaybackManager: AudioPlaybackManager
+
+ private lateinit var exoplayerController0: ExoplayerController
+ private lateinit var exoplayerController1: ExoplayerController
+ private lateinit var timingUtil0: TimingUtil
+ private lateinit var timingUtil1: TimingUtil
+ private lateinit var bAudio: ImageView
+ private lateinit var audioBar: View
+ private lateinit var buttonPlay: ImageView
+ private lateinit var buttonPrevVerse: ImageView
+ private lateinit var buttonNextVerse: ImageView
+ private lateinit var buttonPrevChapter: ImageView
+ private lateinit var buttonNextChapter: ImageView
+ private lateinit var buttonSpeed: ImageView
+ private lateinit var buttonRepeat: ImageView
+ private var isAudioVisible = false
+ private var isRepeatMode = true
+ private var isNight: Boolean by Delegates.observable(false) { _, old, new ->
+ if (old != new) {
+ updateAudioButtonIcon(new)
+ }
+ }
+
lateinit var floater: Floater
private lateinit var backForwardListController: BackForwardListController
private var fullscreenReferenceToast: Toast? = null
@@ -1113,6 +1158,65 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
bRight = findViewById(R.id.bRight)
bVersion = findViewById(R.id.bVersion)
+ cSplitVersion = findViewById(R.id.cSplitVersion)
+ cNightMode = findViewById(R.id.cNightMode)
+
+ //audio
+ bAudio = findViewById(R.id.iconAudio)
+ audioBar = findViewById(R.id.audiobar)
+ buttonPlay = findViewById(R.id.button_play)
+ buttonPrevVerse = findViewById(R.id.button_prev_verse)
+ buttonNextVerse = findViewById(R.id.button_next_verse)
+ buttonPrevChapter = findViewById(R.id.button_prev_chapter)
+ buttonNextChapter = findViewById(R.id.button_next_chapter)
+ buttonSpeed = findViewById(R.id.button_speed)
+ buttonRepeat = findViewById(R.id.button_repeat)
+
+ exoplayerController0 = ExoplayerController(this, scope = lifecycleScope).apply {
+ controllerId = 0
+ setCallback(this@IsiActivity)
+ setUI(this@IsiActivity, this@IsiActivity)
+ }
+ timingUtil0 = TimingUtil(exoplayerController0, this, id = "controller0", scope = lifecycleScope)
+ exoplayerController0.initTimingUtil(timingUtil0)
+
+ exoplayerController1 = ExoplayerController(this, scope = lifecycleScope).apply {
+ controllerId = 1
+ setCallback(this@IsiActivity)
+ setUI(this@IsiActivity, this@IsiActivity)
+ }
+ timingUtil1 = TimingUtil(exoplayerController1, this, id = "controller1", scope = lifecycleScope)
+ exoplayerController1.initTimingUtil(timingUtil1)
+
+ audioPlaybackManager = AudioPlaybackManager(
+ scope = lifecycleScope,
+ controller0 = exoplayerController0,
+ controller1 = exoplayerController1,
+ timing0 = timingUtil0,
+ timing1 = timingUtil1,
+ isSplitMode = { cSplitVersion.isChecked },
+ getBookName = { activeSplit0.book },
+ getVersion = { activeSplit0.version },
+ getChapterNumber = { chapter_1 },
+ displayChapter = { _, chapter ->
+ display(chapter, 1)
+ },
+ buildAudioForChapter = {
+ updateAudioForNewChapter()
+ },
+ onHighlight = { verse, color ->
+ onHighlightVerse(verse, color)
+ },
+ onScroll = { verse ->
+ scrollToHighlightedVerse(verse)
+ },
+ onPlayerStateChanged = { isPlaying ->
+ buttonPlay.setImageResource(
+ if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play
+ )
+ }
+ )
+
overlayContainer = findViewById(R.id.overlayContainer)
root = findViewById(R.id.root)
splitRoot = findViewById(R.id.splitRoot)
@@ -1133,8 +1237,11 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
splitRoot.setListener(splitRoot_listener)
- bGoto.setOnClickListener { bGoto_click() }
+ bGoto.setOnClickListener {
+ bGoto_click()
+ }
bGoto.setOnLongClickListener {
+ audioPlaybackManager.stopAllAudio()
bGoto_longClick()
true
}
@@ -1146,6 +1253,80 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
floater.setListener(floater_listener)
+ val panelBackForwardList = findViewById(R.id.panelBackForwardList)
+
+ isNight = cNightMode.isChecked
+
+ audioBar.post {
+ val audioControls = audioBar.findViewById(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)")
+ }
+
+ //audio feature
+ bAudio.setOnClickListener {
+ checkInternetAndRun {
+ if (!isAudioVisible) {
+ audioPlaybackManager.toggleAudioBar(audioBar, panelBackForwardList)
+ exoplayerController0.setAudioBarVisible(true)
+ val audioControls = audioBar.findViewById(R.id.audio_controls)
+ val color = if (cNightMode.isChecked) "#121212".toColorInt() else "#455A64".toColorInt()
+ audioControls?.setBackgroundColor(color)
+ isAudioVisible = true
+ } else {
+ audioPlaybackManager.hideAudioBar(audioBar, panelBackForwardList)
+ isAudioVisible = false
+ }
+ }
+ }
+
+ buttonPlay.setOnClickListener {
+ val isSplit = cSplitVersion.isChecked
+
+ if (!NetworkUtil.isInternetAvailable(this)) {
+ NetworkUtil.showNoInternetDialog(this)
+ return@setOnClickListener
+ }
+
+ ensureAudioReady {
+ if (isSplit) {
+ AppLog.d(TAG, "Mode DUAL AUDIO dipilih")
+ timingUtil0.mode = TimingUtil.Mode.DUAL_AUDIO
+ timingUtil1.mode = TimingUtil.Mode.DUAL_AUDIO
+ audioPlaybackManager.togglePlayPauseDual()
+ } else {
+ AppLog.d(TAG, "Mode SINGLE AUDIO dipilih")
+ timingUtil0.mode = TimingUtil.Mode.SINGLE_AUDIO
+ audioPlaybackManager.togglePlayPauseSingle()
+ }
+ }
+ }
+
+ buttonPrevVerse.setOnClickListener { audioPlaybackManager.navigateVerse(false) }
+ buttonNextVerse.setOnClickListener { audioPlaybackManager.navigateVerse(true) }
+ buttonPrevChapter.setOnClickListener { audioPlaybackManager.navigateChapter(false) }
+ buttonNextChapter.setOnClickListener { audioPlaybackManager.navigateChapter(true) }
+
+ buttonSpeed.setOnClickListener { audioPlaybackManager.showSpeedMenu(it) }
+ buttonRepeat.setOnClickListener {
+ isRepeatMode = !isRepeatMode
+
+ val iconRes = if (isRepeatMode) R.drawable.ic_repeat else R.drawable.ic_next_chapter
+ buttonRepeat.setImageResource(iconRes)
+
+ val message = if (isRepeatMode) R.string.repeat else R.string.next_chapters
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
+
+ AppLog.d(TAG, "Repeat toggled: now isRepeatMode=$isRepeatMode")
+
+ if (isRepeatMode) {
+ AppLog.d(TAG, "Repeat mode ON -> Navigating to first verse")
+ } else {
+ AppLog.d(TAG, "Repeat mode OFF -> Navigating to next chapter")
+ }
+ }
+
// listeners
lsSplit0 = VersesControllerImpl(
findViewById(R.id.lsSplitView0),
@@ -1194,7 +1375,7 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
}
backForwardListController = BackForwardListController(
- group = findViewById(R.id.panelBackForwardList),
+ group = panelBackForwardList,
onBackButtonNeedUpdate = { button, ari ->
if (ari == 0) {
button.isEnabled = false
@@ -1331,6 +1512,287 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
AppLog.d(TAG, "@@onCreate end")
}
+ private fun checkInternetAndRun(action: () -> Unit) {
+ if (!NetworkUtil.isInternetAvailable(this)) {
+ NetworkUtil.showNoInternetDialog(this)
+ return
+ }
+ action()
+ }
+
+ //audio function
+
+ private fun updateAudioButtonIcon(isNight: Boolean) {
+ val iconRes = if (isNight) R.drawable.ic_audio_night else R.drawable.ic_audio
+ bAudio.setImageResource(iconRes)
+ }
+
+ private fun generateAudioUrl(): String {
+ val urls = mutableListOf()
+
+ if (cSplitVersion.isChecked) {
+ val book = activeSplit0.book
+ val version0 = activeSplit0.version.shortName
+ val version1 = activeSplit1?.version?.shortName
+
+ val url0 = buildAudioUrl(book, chapter_1, version0)
+ AppLog.d(TAG, "Generated URL for version0 ($version0): $url0")
+ if (url0.isNotEmpty()) urls.add(url0)
+
+ if (!version1.isNullOrEmpty()) {
+ val url1 = buildAudioUrl(book, chapter_1, version1)
+ AppLog.d(TAG, "Generated URL for version1 ($version1): $url1")
+ if (url1.isNotEmpty()) urls.add(url1)
+ }
+ } else {
+ val book0 = activeSplit0.book
+ val version0 = activeSplit0.version.shortName
+ val url0 = buildAudioUrl(book0, chapter_1, version0)
+ AppLog.d(TAG, "Generated URL for single mode ($version0): $url0")
+
+ if (url0.isNotEmpty()) urls.add(url0)
+ }
+
+ val joinedUrl = urls.joinToString(";")
+ AppLog.d(TAG, "Final joined URL(s): $joinedUrl")
+ return joinedUrl
+ }
+
+ private fun updateAudioForNewChapter(bookId: Int? = null, chapter: Int? = null, verse: Int? = null) {
+ val isSplit = cSplitVersion.isChecked
+ val book = bookId?.let { activeSplit0.version.getBook(it) } ?: activeSplit0.book
+ val bookName = book.shortName
+ val chapterNumber = chapter ?: chapter_1
+ val version0 = activeSplit0.version.shortName
+
+ AppLog.d(TAG, "updateAudioForNewChapter() → book=$bookName, chapter=$chapterNumber, splitMode=$isSplit")
+
+ if (isSplit) {
+ // --- Dual Audio ---
+ val version1 = activeSplit1?.version?.shortName ?: version0
+
+ 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)
+ }
+
+ // Reset & siapkan audio controller 1
+ timingUtil1.clearTimingList()
+ timingUtil1.resetHighlight()
+ with(exoplayerController1) {
+ stopSegment()
+ mediaKnownToExist(url1)
+ setAudioUrl(url1)
+ state = MediaController.State.reset_media_known_to_exist
+ playOrPause(false)
+ }
+
+ // Muat timing file untuk kedua versi
+ timingUtil0.loadTimingFile(bookName, chapterNumber.toString(), version0, onSuccess = {
+ AppLog.d(TAG, "TimingUtil0 loaded for $version0")
+ })
+ timingUtil1.loadTimingFile(bookName, chapterNumber.toString(), version1, onSuccess = {
+ AppLog.d(TAG, "TimingUtil1 loaded for $version1")
+ })
+
+ AppLog.d(TAG, "updateAudioForNewChapter() → Dual timing load selesai")
+
+ } else {
+ // --- Single Audio ---
+ val url = buildAudioUrl(book, chapter_1,version0)
+ AppLog.d(TAG, "Single mode URL → $url")
+
+ timingUtil0.clearTimingList()
+ timingUtil0.resetHighlight()
+
+ with(exoplayerController0) {
+ playOrPause(false)
+ mediaKnownToExist(url)
+ setAudioUrl(url)
+ state = MediaController.State.reset_media_known_to_exist
+ }
+
+ timingUtil0.loadTimingFile(bookName, chapterNumber.toString(), version0, onSuccess = {
+ AppLog.d(TAG, "TimingUtil0 loaded for single mode ($version0)")
+ })
+
+ AppLog.d(TAG, "updateAudioForNewChapter() → Single timing load selesai")
+ }
+ }
+
+ 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")
+
+ 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 --
+ var timing0Loaded = false
+ var timing1Loaded = !cSplitVersion.isChecked // true if not dual-mode
+
+ fun tryCallOnReady() {
+ if (timing0Loaded && timing1Loaded) {
+ AppLog.d(TAG, "✅ Semua timing files berhasil dimuat — onReady() dipanggil")
+ onReady()
+ }
+ }
+
+ // timing untuk controller0
+ timingUtil0.loadTimingFile(
+ book,
+ chapter.toString(),
+ version0,
+ onSuccess = {
+ AppLog.d(TAG, "Timing controller0 berhasil dimuat")
+ timing0Loaded = true
+ tryCallOnReady()
+ },
+ 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()
+ }
+ )
+ } else {
+ AppLog.w(TAG, "Split version is active but version1 is null or empty, not loading timing file for controller 1.")
+ timing1Loaded = true
+ tryCallOnReady()
+ }
+ }
+ }
+
+ override fun onPlayerStateChanged(isPlaying: Boolean) {
+ buttonPlay.setImageResource(if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play)
+ }
+
+ override fun onAudioEnded() {
+ AppLog.d(TAG, "onAudioEnded called. isRepeatMode=$isRepeatMode chapter=$chapter_1 book=${activeSplit0.book.shortName}")
+
+ audioPlaybackManager.stopAllAudio()
+
+ timingUtil0.resetHighlight()
+ timingUtil1.resetHighlight()
+ timingUtil0.currentVerseIndex = -1
+ timingUtil1.currentVerseIndex = -1
+
+ if (isRepeatMode) {
+ AppLog.d(TAG, "Repeat mode ON → Restarting current chapter")
+ display(chapter_1, 1)
+ timingUtil0.highlightVerse(1)
+ if (cSplitVersion.isChecked) timingUtil1.highlightVerse(1)
+ } else {
+ AppLog.d(TAG, "Repeat mode OFF → Moving to next chapter")
+ // Pastikan mulai dari ayat pertama
+ audioPlaybackManager.navigateChapter(true, toFirstVerse = true)
+ lifecycleScope.launch {
+ delay(800)
+ timingUtil0.highlightVerse(1)
+ if (cSplitVersion.isChecked) timingUtil1.highlightVerse(1)
+
+ exoplayerController0.playFromVerse(1, timingUtil0.timingList)
+ if (cSplitVersion.isChecked) {
+ exoplayerController1.playFromVerse(1, timingUtil1.timingList)
+ }
+ }
+ }
+
+ AppLog.d(TAG, "onAudioEnded → moved to next or restarted chapter from verse 1")
+ }
+
+ override fun onHighlightVerse(verseNumber: Int, color: Int) {
+ val adjustedVerse = verseNumber - 1
+
+ listOf(R.id.lsSplitView0, R.id.lsSplitView1).forEach { viewId ->
+ (findViewById(viewId)?.adapter as? VersesAdapter)
+ ?.updateHighlight(adjustedVerse, color)
+ }
+
+ AppLog.d(TAG, "onHighlightVerse → original=$verseNumber, adjusted=$adjustedVerse")
+ }
+
+ override fun applyHighlight(verseNumber: Int, color: Int) {
+ val selectedVerses = IntArrayList().apply { add(verseNumber) }
+ exoplayerController0.highlightVerses(selectedVerses, color)
+ }
+
+ override fun scrollToHighlightedVerse(verseNumber: Int) {
+ lsSplit0.scrollToVerse(verseNumber)
+
+ activeSplit1?.let { lsSplit1.scrollToVerse(verseNumber) }
+ }
+
+ override fun onControllerStateChanged(state: MediaController.State) {
+ Log.d(TAG, "onControllerStateChanged: $state")
+ }
+
+ // end audio function
+
private fun callAttentionForVerseToBothSplits(verse_1: Int) {
lsSplit0.callAttentionForVerse(verse_1)
lsSplit1.callAttentionForVerse(verse_1)
@@ -1419,6 +1881,8 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
}
fun loadVersion(mv: MVersion) {
+ audioPlaybackManager.stopAllAudio()
+
try {
val version = mv.version ?: throw RuntimeException() // caught below
@@ -1638,6 +2102,7 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
if (callAttention && ari == Ari.encode(activeSplit0.book.bookId, ari_cv)) {
callAttentionForVerseToBothSplits(Ari.toVerse(ari))
}
+ updateAudioForNewChapter()
}
fun referenceFromSelectedVerses(selectedVerses: IntArrayList, book: Book): String {
@@ -1823,6 +2288,7 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
private fun bGoto_click() {
val r = {
+ audioPlaybackManager.stopAllAudio()
startActivityForResult(GotoActivity.createIntent(activeSplit0.book.bookId, this.chapter_1, getVerse_1BasedOnScrolls()), RequestCodes.FromActivity.Goto)
}
@@ -1850,6 +2316,7 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
}
private fun bGoto_longClick() {
+ audioPlaybackManager.stopAllAudio()
if (history.size > 0) {
MaterialDialog(this).show {
withAdapter(HistoryAdapter())
@@ -2188,6 +2655,7 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == RequestCodes.FromActivity.Goto && resultCode == RESULT_OK && data != null) {
+ audioPlaybackManager.stopAllAudio()
val result = GotoActivity.obtainResult(data)
if (result != null) {
val ari_cv: Int
@@ -2230,6 +2698,8 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
// Add target ari to history
history.add(target_ari)
backForwardListController.newEntry(target_ari)
+
+ updateAudioForNewChapter()
}
} else if (requestCode == RequestCodes.FromActivity.TextAppearanceGetFonts) {
textAppearancePanel?.onActivityResult(requestCode)
@@ -2406,6 +2876,7 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
}
fun bLeft_click() {
+ audioPlaybackManager.stopAllAudio()
val currentBook = activeSplit0.book
if (chapter_1 == 1) {
// we are in the beginning of the book, so go to prev book
@@ -2416,6 +2887,7 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
activeSplit0 = activeSplit0.copy(book = newBook)
val newChapter_1 = newBook.chapter_count // to the last chapter
display(newChapter_1, 1)
+ updateAudioForNewChapter(newBook.bookId, newChapter_1, 1)
break
}
tryBookId--
@@ -2424,10 +2896,12 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
} else {
val newChapter = chapter_1 - 1
display(newChapter, 1)
+ updateAudioForNewChapter(currentBook.bookId, newChapter, 1)
}
}
fun bRight_click() {
+ audioPlaybackManager.stopAllAudio()
val currentBook = activeSplit0.book
if (chapter_1 >= currentBook.chapter_count) {
val maxBookId = activeSplit0.version.maxBookIdPlusOne
@@ -2437,6 +2911,7 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
if (newBook != null) {
activeSplit0 = activeSplit0.copy(book = newBook)
display(1, 1)
+ updateAudioForNewChapter(newBook.bookId, 1, 1)
break
}
tryBookId++
@@ -2445,6 +2920,7 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
} else {
val newChapter = chapter_1 + 1
display(newChapter, 1)
+ updateAudioForNewChapter(currentBook.bookId, newChapter, 1)
}
}
@@ -2539,7 +3015,7 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
holder.lSnippet.visibility = View.GONE
val labels = S.db.listLabelsByMarker(marker)
- if (labels.size != 0) {
+ if (labels.isNotEmpty()) {
holder.panelLabels.visibility = View.VISIBLE
holder.panelLabels.removeAllViews()
for (label in labels) {
@@ -2820,6 +3296,13 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
override fun cNightMode_checkedChange(isChecked: Boolean) {
setNightMode(isChecked)
+
+ audioBar.post {
+ val color = if (isChecked) "#121212".toColorInt() else "#455A64".toColorInt()
+ val audioControls = audioBar.findViewById(R.id.audio_controls)
+ audioControls?.setBackgroundColor(color)
+ Log.d("IsiActivity", "Changed audio_controls background to $color (isNight=$isChecked)")
+ }
}
override fun cSplitVersion_checkedChange(cSplitVersion: SwitchCompat, isChecked: Boolean) {
@@ -2864,6 +3347,8 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener {
leftDrawer.closeDrawer()
}
+ fun getAudioPlaybackManager(): AudioPlaybackManager = audioPlaybackManager
+
private fun gotoProgressMark(preset_id: Int) {
val progressMark = S.db.getProgressMarkByPresetId(preset_id) ?: return
diff --git a/Alkitab/src/main/java/yuku/alkitab/base/model/MAudio.kt b/Alkitab/src/main/java/yuku/alkitab/base/model/MAudio.kt
new file mode 100644
index 000000000..e1eced989
--- /dev/null
+++ b/Alkitab/src/main/java/yuku/alkitab/base/model/MAudio.kt
@@ -0,0 +1,10 @@
+package yuku.alkitab.base.model
+
+data class MAudio(
+ val version: String,
+ val audio1: String,
+ val audio2: String? = null,
+ val audio3: String? = null,
+ val audio4: String? = null,
+ val audio5: String? = null
+)
diff --git a/Alkitab/src/main/java/yuku/alkitab/base/model/MTiming.kt b/Alkitab/src/main/java/yuku/alkitab/base/model/MTiming.kt
new file mode 100644
index 000000000..19ff1d8f5
--- /dev/null
+++ b/Alkitab/src/main/java/yuku/alkitab/base/model/MTiming.kt
@@ -0,0 +1,27 @@
+package yuku.alkitab.base.model
+
+import org.json.JSONObject
+
+data class MTiming(
+ val startTime: Long,
+ val endTime: Long,
+ val verseNumber: Int
+) {
+ companion object {
+ fun fromJson(json: JSONObject): MTiming {
+ val startTime = (json.getString("time_start").toFloat() * 1000).toLong()
+ val duration = (json.getString("duration").toFloat() * 1000).toLong()
+ val endTime = startTime + duration
+ val verseNumber = json.getInt("verse")
+
+ return MTiming(startTime, endTime, verseNumber)
+ }
+
+ fun fromJsonArray(jsonText: String): List {
+ val timestampsArray = JSONObject(jsonText).getJSONArray("timestamps")
+ return List(timestampsArray.length()) { i ->
+ fromJson(timestampsArray.getJSONObject(i))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Alkitab/src/main/java/yuku/alkitab/base/util/AudioPlaybackManager.kt b/Alkitab/src/main/java/yuku/alkitab/base/util/AudioPlaybackManager.kt
new file mode 100644
index 000000000..a3552678e
--- /dev/null
+++ b/Alkitab/src/main/java/yuku/alkitab/base/util/AudioPlaybackManager.kt
@@ -0,0 +1,323 @@
+package yuku.alkitab.base.util
+
+import android.content.res.Resources
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.PopupMenu
+import androidx.lifecycle.LifecycleCoroutineScope
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.math.min
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import yuku.alkitab.base.model.MTiming
+import yuku.alkitab.model.Book
+import yuku.alkitab.model.Version
+import yuku.alkitab.songs.ExoplayerController
+import yuku.alkitab.songs.MediaController
+
+class AudioPlaybackManager(
+ private val scope: LifecycleCoroutineScope,
+ private val controller0: ExoplayerController,
+ private val controller1: ExoplayerController,
+ private val timing0: TimingUtil,
+ private val timing1: TimingUtil,
+ private val isSplitMode: () -> Boolean,
+ private val getBookName: () -> Book,
+ private val getVersion: () -> Version,
+ private val getChapterNumber: () -> Int,
+ private val displayChapter: (Book, Int) -> Unit,
+ private val buildAudioForChapter: () -> Unit,
+ private val onHighlight: (verse: Int, color: Int) -> Unit,
+ private val onScroll: (verse: Int) -> Unit,
+ private val onPlayerStateChanged: (isPlaying: Boolean) -> Unit,
+) {
+ private var dualJob: Job? = null
+ private var currentSegmentIndex = -1
+ private var isAudioVisible = false
+
+ // === AUDIO BAR TOGGLE ===
+ fun toggleAudioBar(audioBar: View, panelBackForwardList: LinearLayout) {
+ isAudioVisible = !isAudioVisible
+ audioBar.visibility = if (isAudioVisible) View.VISIBLE else View.GONE
+ val params = panelBackForwardList.layoutParams as ViewGroup.MarginLayoutParams
+ params.bottomMargin = if (isAudioVisible) dpToPx(AUDIO_BAR_MARGIN_DP) else 0
+ panelBackForwardList.layoutParams = params
+ }
+
+ fun hideAudioBar(audioBar: View, panelBackForwardList: LinearLayout) {
+ if (!isAudioVisible) return
+
+ isAudioVisible = false
+ audioBar.visibility = View.GONE
+
+ val params = panelBackForwardList.layoutParams as ViewGroup.MarginLayoutParams
+ params.bottomMargin = 0
+ panelBackForwardList.layoutParams = params
+ }
+
+ private fun dpToPx(dp: Int): Int = (dp * Resources.getSystem().displayMetrics.density).toInt()
+
+ // === STOP & RESET ===
+ fun stopAllAudio() {
+ AppLog.d("AudioPlaybackManager", "🔇 stopAllAudio() called")
+
+ // Batalkan job paralel
+ dualJob?.cancel()
+ dualJob = null
+
+ // Hentikan semua playback di kedua controller
+ try {
+ controller0.stopSegment()
+ controller1.stopSegment()
+
+ controller0.mp.playWhenReady = false
+ controller1.mp.playWhenReady = false
+
+ controller0.reset()
+ controller1.reset()
+
+ timing0.resetHighlight()
+ timing1.resetHighlight()
+ } catch (e: Exception) {
+ AppLog.e("AudioPlaybackManager", "Error stopping audio: ${e.message}")
+ }
+ }
+
+ // === PLAYBACK: SINGLE MODE ===
+ fun togglePlayPauseSingle() {
+ val version = getVersion()
+ val book = getBookName()
+ val chapter = getChapterNumber().toString()
+
+ if (timing0.timingList.isNotEmpty()) {
+ if (controller0.state == MediaController.State.playing) {
+ controller0.playOrPause(false)
+ timing0.resetHighlight()
+ onPlayerStateChanged(false)
+ } else {
+ timing0.startHighlightingVerses()
+ controller0.playOrPause(true)
+ onPlayerStateChanged(true)
+ }
+ } else {
+ timing0.loadTimingFile(book.shortName, chapter, version.shortName,
+ onSuccess = {
+ timing0.startHighlightingVerses()
+ controller0.playOrPause(true)
+ onPlayerStateChanged(true)
+ },
+ onError = {
+ AppLog.e("AudioPlaybackManager", "Gagal memuat timing: $it")
+ }
+ )
+ }
+ }
+
+ // === PLAYBACK: DUAL MODE ===
+ fun togglePlayPauseDual() {
+ val version = getVersion()
+ val book = getBookName()
+ val chapter = getChapterNumber().toString()
+
+ if (timing0.timingList.isEmpty() || timing1.timingList.isEmpty()) {
+ // load timing dulu
+ timing0.loadTimingFile(book.shortName, chapter, version.shortName, onSuccess = {
+ timing1.loadTimingFile(book.shortName, chapter, version.shortName, onSuccess = {
+ playAlternating(controller0, controller1, timing0.timingList, timing1.timingList)
+ })
+ })
+ return
+ }
+
+ if (controller0.state == MediaController.State.playing || controller1.state == MediaController.State.playing) {
+ stopAllAudio()
+ onPlayerStateChanged(false)
+ } else {
+ playAlternating(controller0, controller1, timing0.timingList, timing1.timingList, currentSegmentIndex)
+ onPlayerStateChanged(true)
+ }
+ }
+
+ // === NAVIGASI AYAT ===
+ fun navigateVerse(isNext: Boolean) {
+ if (isSplitMode()) navigateVerseDual(isNext)
+ else navigateVerseSingle(isNext)
+ }
+
+ private fun navigateVerseSingle(isNext: Boolean) {
+ val index = timing0.currentVerseIndex
+ if (index == -1) return
+
+ val targetIndex = if (isNext) index + 1 else index - 1
+ if (targetIndex in timing0.timingList.indices) {
+ val target = timing0.timingList[targetIndex]
+ timing0.highlightVerse(target.verseNumber)
+ controller0.seekTo(target.startTime)
+ controller0.playFromVerse(target.verseNumber, timing0.timingList)
+ } else {
+ if (isNext) navigateChapter(true, toFirstVerse = true)
+ else navigateChapter(false, toLastVerse = true)
+ }
+ }
+
+ private fun navigateVerseDual(isNext: Boolean) {
+ val index = currentSegmentIndex
+ if (index == -1) return
+
+ val targetIndex = if (isNext) index + 1 else index - 1
+ if (targetIndex in timing0.timingList.indices && targetIndex in timing1.timingList.indices) {
+ val v0 = timing0.timingList[targetIndex]
+ val v1 = timing1.timingList[targetIndex]
+ timing0.highlightVerse(v0.verseNumber)
+ timing1.highlightVerse(v1.verseNumber)
+ playAlternating(controller0, controller1, timing0.timingList, timing1.timingList, targetIndex)
+ } else {
+ if (isNext) navigateChapter(true, toFirstVerse = true)
+ else navigateChapter(false, toLastVerse = true)
+ }
+ }
+
+ // === NAVIGASI PASAL ===
+ fun navigateChapter(isNext: Boolean, toFirstVerse: Boolean = false, toLastVerse: Boolean = false) {
+ stopAllAudio()
+
+ timing0.resetHighlight()
+ timing1.resetHighlight()
+ currentSegmentIndex = -1
+
+ val target = getNextOrPreviousChapter(isNext) ?: return
+ val (book, chapter) = target
+
+ // ubah state buku & tampilkan
+ displayChapter(book, chapter)
+ buildAudioForChapter()
+
+ // atur highlight
+ if (isSplitMode()) {
+ when {
+ toFirstVerse -> {
+ timing0.highlightVerse(1)
+ timing1.highlightVerse(1)
+ }
+ toLastVerse -> {
+ val lastVerse = min(
+ timing0.timingList.lastOrNull()?.verseNumber ?: 1,
+ timing1.timingList.lastOrNull()?.verseNumber ?: 1
+ )
+ timing0.highlightVerse(lastVerse)
+ timing1.highlightVerse(lastVerse)
+ }
+ }
+ } else {
+ when {
+ toFirstVerse -> timing0.highlightVerse(1)
+ toLastVerse -> {
+ val lastVerse = timing0.timingList.lastOrNull()?.verseNumber ?: 1
+ timing0.highlightVerse(lastVerse)
+ }
+ }
+ }
+ }
+
+ private fun getNextOrPreviousChapter(isNext: Boolean): Pair? {
+ val currentBook = getBookName()
+ val currentChapter = getChapterNumber()
+ val version = getVersion()
+
+ return if (isNext) {
+ if (currentChapter >= currentBook.chapter_count) {
+ var nextBookId = currentBook.bookId + 1
+ while (nextBookId < version.maxBookIdPlusOne) {
+ version.getBook(nextBookId)?.let { return it to 1 }
+ nextBookId++
+ }
+ null
+ } else {
+ currentBook to (currentChapter + 1)
+ }
+ } else {
+ if (currentChapter == 1) {
+ var prevBookId = currentBook.bookId - 1
+ while (prevBookId >= 0) {
+ version.getBook(prevBookId)?.let { return it to it.chapter_count }
+ prevBookId--
+ }
+ null
+ } else {
+ currentBook to (currentChapter - 1)
+ }
+ }
+ }
+
+ // === PLAY ALTERNATING (Dual Mode) ===
+ fun playAlternating(
+ c0: ExoplayerController,
+ c1: ExoplayerController,
+ list0: List,
+ list1: List,
+ startIndex: Int = 0
+ ) {
+ dualJob?.cancel()
+ dualJob = scope.launch {
+ try {
+ val size = min(list0.size, list1.size)
+ for (i in startIndex until size) {
+ currentSegmentIndex = i
+ val (start0, end0, _) = list0[i]
+ val (start1, end1, _) = list1[i]
+
+ c0.stopSegment(); c1.stopSegment()
+
+ suspendCancellableCoroutine { cont ->
+ c0.playSegment(start0, end0) {
+ if (cont.isActive) cont.resume(Unit) {}
+ }
+ }
+
+ ensureActive()
+
+ suspendCancellableCoroutine { cont ->
+ c1.playSegment(start1, end1) {
+ if (cont.isActive) cont.resume(Unit) {}
+ }
+ }
+ }
+ } catch (e: CancellationException) {
+ c0.stopSegment(); c1.stopSegment()
+ }
+ }
+ }
+
+ // === PLAYBACK SPEED MENU ===
+ fun showSpeedMenu(anchor: View) {
+ val popup = PopupMenu(anchor.context, anchor)
+ val options = listOf(
+ 0.5f to "0.5×", 0.8f to "0.8×", 1.0f to "Normal",
+ 1.1f to "1.1×", 1.25f to "1.25×", 1.5f to "1.5×", 1.75f to "1.75×", 2.0f to "2.0×"
+ )
+
+ options.forEachIndexed { idx, (_, label) -> popup.menu.add(0, idx, 0, label) }
+ popup.menu.setGroupCheckable(0, true, true)
+
+ val currentSpeed = controller0.getPlaybackSpeed()
+ val currentIndex = options.indexOfFirst { it.first == currentSpeed }
+ if (currentIndex != -1) popup.menu.findItem(currentIndex)?.isChecked = true
+
+ popup.setOnMenuItemClickListener { item ->
+ val speed = options.getOrNull(item.itemId)?.first ?: return@setOnMenuItemClickListener true
+ controller0.setPlaybackSpeed(speed)
+ if (isSplitMode()) controller1.setPlaybackSpeed(speed)
+ true
+ }
+
+ popup.show()
+ }
+
+ companion object{
+ const val AUDIO_BAR_MARGIN_DP = 75
+ }
+
+}
\ No newline at end of file
diff --git a/Alkitab/src/main/java/yuku/alkitab/base/util/BibleMediaManager.kt b/Alkitab/src/main/java/yuku/alkitab/base/util/BibleMediaManager.kt
new file mode 100644
index 000000000..23cf9f117
--- /dev/null
+++ b/Alkitab/src/main/java/yuku/alkitab/base/util/BibleMediaManager.kt
@@ -0,0 +1,112 @@
+package yuku.alkitab.base.util
+
+import java.util.Locale
+import kotlin.to
+import yuku.alkitab.model.Book
+
+object BibleMediaManager {
+ private const val BASE_URL = "https://media.sabda.org/alkitab_audio"
+ private const val SUBDIR_PL = "pl/mp3/cd"
+ private const val SUBDIR_PB = "pb/mp3/cd"
+ private const val LAST_OLD_TESTAMENT_BOOK_ID = 38
+ val bookAbbrMap: Map = mapOf(
+ "Kejadian" to "kej",
+ "Keluaran" to "kel",
+ "Imamat" to "ima",
+ "Bilangan" to "bil",
+ "Ulangan" to "ula",
+ "Yosua" to "yos",
+ "Hakim-hakim" to "hak",
+ "Rut" to "rut",
+ "1 Samuel" to "1sa",
+ "2 Samuel" to "2sa",
+ "1 Raja-raja" to "1ra",
+ "2 Raja-raja" to "2ra",
+ "1 Tawarikh" to "1ta",
+ "2 Tawarikh" to "2ta",
+ "Ezra" to "ezr",
+ "Nehemia" to "neh",
+ "Ester" to "est",
+ "Ayub" to "ayb",
+ "Mazmur" to "mzm",
+ "Amsal" to "ams",
+ "Pengkhotbah" to "pkh",
+ "Kidung Agung" to "kid",
+ "Yesaya" to "yes",
+ "Yeremia" to "yer",
+ "Ratapan" to "rat",
+ "Yehezkiel" to "yeh",
+ "Daniel" to "dan",
+ "Hosea" to "hos",
+ "Yoel" to "yoe",
+ "Amos" to "amo",
+ "Obaja" to "oba",
+ "Yunus" to "yun",
+ "Mikha" to "mik",
+ "Nahum" to "nah",
+ "Habakuk" to "hab",
+ "Zefanya" to "zef",
+ "Hagai" to "hag",
+ "Zakharia" to "zak",
+ "Maleakhi" to "mal",
+ "Matius" to "mat",
+ "Markus" to "mrk",
+ "Lukas" to "luk",
+ "Yohanes" to "yoh",
+ "Kisah Para Rasul" to "kis",
+ "Roma" to "rom",
+ "1 Korintus" to "1ko",
+ "2 Korintus" to "2ko",
+ "Galatia" to "gal",
+ "Efesus" to "efe",
+ "Filipi" to "fil",
+ "Kolose" to "kol",
+ "1 Tesalonika" to "1te",
+ "2 Tesalonika" to "2te",
+ "1 Timotius" to "1ti",
+ "2 Timotius" to "2ti",
+ "Titus" to "tit",
+ "Filemon" to "flm",
+ "Ibrani" to "ibr",
+ "Yakobus" to "yak",
+ "1 Petrus" to "1pe",
+ "2 Petrus" to "2pe",
+ "1 Yohanes" to "1yo",
+ "2 Yohanes" to "2yo",
+ "3 Yohanes" to "3yo",
+ "Yudas" to "yud",
+ "Wahyu" to "wah"
+ )
+
+ fun getCanonicalShortName(name: String): String = when (name) {
+ "Hakim-hakim" -> "Hakim"
+ "1 Raja-raja" -> "1Raja"
+ "2 Raja-raja" -> "2Raja"
+ "Kidung Agung" -> "Kidung"
+ "Kisah Para Rasul" -> "Kisah"
+ else -> name
+ }
+
+ // == AUDIO VERSION ==
+
+ fun getSpecialAudioVersion(version: String): String{
+ return when (version.uppercase()){
+ "TB" -> "tbsuara"
+ // bisa ditambahkan sesuai kebutuhan
+ else -> version.lowercase()
+ }
+ }
+
+ fun buildAudioUrl(book: Book, chapter: Int, version: String): String {
+ val bookAbbr = bookAbbrMap[book.shortName] ?: return ""
+ val audioVersion = MediaList.AUDIO.find { it.version == version }?.audio1 ?: return ""
+
+ val isPL = book.bookId <= LAST_OLD_TESTAMENT_BOOK_ID // 0–38 = PL, 39–65 = PB
+ val subdir = if (isPL) SUBDIR_PL else SUBDIR_PB
+ val bookCode = String.format(Locale.US, "%02d", if (isPL) book.bookId + 1 else book.bookId - 38)
+ val shortNameClean = getCanonicalShortName(book.shortName)
+ val chapterFormatted = String.format(Locale.US, if (book.chapter_count >= 100) "%03d" else "%02d", chapter)
+
+ return "$BASE_URL/$audioVersion/$subdir/${bookCode}_${shortNameClean.lowercase()}/${bookCode}_${bookAbbr}${chapterFormatted}.mp3"
+ }
+}
\ No newline at end of file
diff --git a/Alkitab/src/main/java/yuku/alkitab/base/util/MediaList.kt b/Alkitab/src/main/java/yuku/alkitab/base/util/MediaList.kt
new file mode 100644
index 000000000..0e1f11f43
--- /dev/null
+++ b/Alkitab/src/main/java/yuku/alkitab/base/util/MediaList.kt
@@ -0,0 +1,13 @@
+package yuku.alkitab.base.util
+
+import yuku.alkitab.base.model.MAudio
+
+object MediaList {
+
+ val AUDIO = listOf(
+ MAudio("TB", "tb_alkitabsuara", "tb", "tb_otnt"),
+ MAudio("AYT", "ayt-ai-v2"),
+ MAudio("AVB", "avb"),
+ MAudio("KJV", "kjv")
+ )
+}
\ No newline at end of file
diff --git a/Alkitab/src/main/java/yuku/alkitab/base/util/NetworkUtil.kt b/Alkitab/src/main/java/yuku/alkitab/base/util/NetworkUtil.kt
new file mode 100644
index 000000000..5475eb5c9
--- /dev/null
+++ b/Alkitab/src/main/java/yuku/alkitab/base/util/NetworkUtil.kt
@@ -0,0 +1,28 @@
+package yuku.alkitab.base.util
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import androidx.appcompat.app.AlertDialog
+import yuku.alkitab.debug.R
+
+object NetworkUtil {
+
+ fun isInternetAvailable(context: Context): Boolean {
+ val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = connectivityManager.activeNetwork ?: return false
+ val networkCapabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
+ return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
+
+ fun showNoInternetDialog(context: Context) {
+ AlertDialog.Builder(context)
+ .setTitle(R.string.network_title)
+ .setMessage(R.string.network_message)
+ .setCancelable(false)
+ .setPositiveButton("OK") { dialog, _ ->
+ dialog.dismiss()
+ }
+ .show()
+ }
+}
\ No newline at end of file
diff --git a/Alkitab/src/main/java/yuku/alkitab/base/util/TimingUtil.kt b/Alkitab/src/main/java/yuku/alkitab/base/util/TimingUtil.kt
new file mode 100644
index 000000000..bbf004331
--- /dev/null
+++ b/Alkitab/src/main/java/yuku/alkitab/base/util/TimingUtil.kt
@@ -0,0 +1,165 @@
+package yuku.alkitab.base.util
+
+import android.util.Log
+import java.net.URL
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import yuku.alkitab.base.model.MTiming
+import yuku.alkitab.songs.ExoplayerController
+
+private const val TAG = "TimingUtil"
+private const val HIGHLIGHT_DELAY_MS = 100L
+
+class TimingUtil(
+ private val exoplayerController: ExoplayerController,
+ private val highlightListener: HighlightListener,
+ var mode: Mode = Mode.SINGLE_AUDIO,
+ private val id: String,
+ private val scope: CoroutineScope
+) {
+ enum class Mode {
+ SINGLE_AUDIO,
+ DUAL_AUDIO
+ }
+
+ var timingList: List = emptyList()
+ var currentVerseIndex = -1
+ var highlightedVerse: Int? = null
+ private var isHighlightingActive = false
+ private var highlightJob: Job? = null
+ private var playbackJob: Job? = null
+
+ /**
+ * Timing File Function
+ */
+
+ fun loadTimingFile(bookName: String, chapter: String, version: String, onSuccess: (() -> Unit)? = null, onError: ((String) -> Unit)? = null) {
+ AppLog.d(TAG, "[$id] loadTimingFile called - bookName: $bookName, chapter: $chapter, version: $version")
+
+ timingList = emptyList()
+ val version = BibleMediaManager.getSpecialAudioVersion(version)
+ val url = "https://karaoke.sabda.org/api/timming.php?book=$bookName&chapter=$chapter&version=$version"
+ AppLog.d(TAG, "[$id] loadTimingFile called - url: $url")
+
+ scope.launch(Dispatchers.IO) {
+ try {
+ val jsonText = URL(url).readText()
+ AppLog.d(TAG, "[$id] Timing file berhasil diambil, mulai parsing...")
+
+ // Parsing JSON dilakukan di MTiming
+ timingList = MTiming.fromJsonArray(jsonText)
+
+ AppLog.d(TAG, "[$id] Timing file berhasil diparse. Total entries: ${timingList.size}")
+ timingList.forEachIndexed { index, it ->
+ AppLog.d(TAG, "[$id] Entry $index → Start: ${it.startTime}, End: ${it.endTime}, Verse: ${it.verseNumber}")
+ }
+
+ withContext(Dispatchers.Main) { onSuccess?.invoke() }
+ } catch (e: Exception) {
+ Log.e(TAG, "[$id] Error loading timing file: ${e.message}", e)
+ withContext(Dispatchers.Main) {
+ onError?.invoke(e.message ?: "Unknown error")
+ }
+ }
+ }
+ }
+
+ /**
+ * For single-audio use only: auto highlight based on ExoPlayer time
+ */
+ fun startHighlightingVerses() {
+ if (mode != Mode.SINGLE_AUDIO) {
+ Log.w(TAG, "[$id] startHighlightingVerses called in DUAL_AUDIO mode – ignored")
+ return
+ }
+
+ if (isHighlightingActive) {
+ Log.d(TAG, "[$id] Highlighting already active, skipping restart")
+ return
+ }
+
+ isHighlightingActive = true
+ Log.d(TAG, "[$id] Starting verse highlighting")
+
+ highlightJob = scope.launch(Dispatchers.IO) {
+ while (isHighlightingActive) {
+ val currentPosition = exoplayerController.getProgress().getOrNull(0)
+
+ if (currentPosition == null) {
+ Log.w(TAG, "[$id] getProgress() mengembalikan null — menghentikan highlighting")
+ break
+ }
+
+ val newVerseIndex = timingList.indexOfFirst {
+ currentPosition in it.startTime..it.endTime
+ }
+
+ if (newVerseIndex != -1 && newVerseIndex != currentVerseIndex) {
+ Log.d(TAG, "[$id] Ayat berubah: dari index $currentVerseIndex ke $newVerseIndex")
+ currentVerseIndex = newVerseIndex
+
+ withContext(Dispatchers.Main) {
+ highlightVerse(timingList[newVerseIndex].verseNumber)
+ }
+ } else if (newVerseIndex == -1) {
+ Log.v(TAG, "[$id] Posisi $currentPosition tidak cocok dengan ayat manapun.")
+ }
+
+ delay(HIGHLIGHT_DELAY_MS)
+ }
+
+ Log.d(TAG, "[$id] Highlighting loop berakhir.")
+ }
+ }
+
+ /**
+ * For dual-audio mode: manually call this per ayat
+ */
+ fun highlightVerse(verseNumber: Int) {
+ Log.d(TAG, "[$id] Permintaan highlight ayat: $verseNumber")
+
+ if (highlightedVerse != verseNumber) {
+ Log.d(TAG, "[$id] Highlighting verse=$verseNumber (previous=${highlightedVerse ?: "none"})")
+
+ clearPreviousHighlight()
+ highlightedVerse = verseNumber
+
+ highlightListener.applyHighlight(verseNumber, -1)
+ highlightListener.scrollToHighlightedVerse(verseNumber)
+ } else {
+ Log.d(TAG, "[$id] Ayat $verseNumber sudah disorot — diabaikan")
+ }
+ }
+
+ private fun clearPreviousHighlight() {
+ highlightedVerse?.let {
+ Log.d(TAG, "[$id] Clearing highlight for verse index=$it")
+ highlightListener.applyHighlight(it, 0)
+ highlightedVerse = null
+ } ?: Log.v(TAG, "[$id] clearPreviousHighlight() — tidak ada highlight aktif.")
+ }
+
+ fun resetHighlight() {
+ Log.d(TAG, "[$id] resetHighlight() — Menonaktifkan highlighting & reset status")
+ isHighlightingActive = false
+ clearPreviousHighlight()
+ currentVerseIndex = -1
+ playbackJob?.cancel()
+ highlightJob?.cancel()
+ }
+
+ fun clearTimingList() {
+ timingList = emptyList()
+ AppLog.d(TAG, "[$id] timingList dihapus sebelum memuat ulang data baru.")
+ }
+
+ interface HighlightListener {
+ fun applyHighlight(verseNumber: Int, color: Int)
+ fun scrollToHighlightedVerse(verseNumber: Int)
+
+ }
+}
\ No newline at end of file
diff --git a/Alkitab/src/main/java/yuku/alkitab/base/verses/VersesControllerImpl.kt b/Alkitab/src/main/java/yuku/alkitab/base/verses/VersesControllerImpl.kt
index ffc625c26..14a66197c 100644
--- a/Alkitab/src/main/java/yuku/alkitab/base/verses/VersesControllerImpl.kt
+++ b/Alkitab/src/main/java/yuku/alkitab/base/verses/VersesControllerImpl.kt
@@ -3,12 +3,14 @@ package yuku.alkitab.base.verses
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.text.SpannableStringBuilder
+import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.TextView
+import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.LinearLayoutManager
@@ -16,6 +18,7 @@ import androidx.recyclerview.widget.RecyclerView
import java.util.concurrent.atomic.AtomicInteger
import yuku.afw.storage.Preferences
import yuku.alkitab.base.S
+import yuku.alkitab.base.storage.Prefkey
import yuku.alkitab.base.util.AppLog
import yuku.alkitab.base.util.Appearances
import yuku.alkitab.base.util.TargetDecoder
@@ -470,6 +473,7 @@ class VerseTextHolder(private val view: VerseItem) : ItemHolder(view) {
checked: Boolean,
toggleChecked: (position: Int) -> Unit,
index: Int,
+ highlightColor: Int,
) {
val verse_1 = index + 1
val ari = Ari.encodeWithBc(data.ari_bc_, verse_1)
@@ -507,6 +511,17 @@ class VerseTextHolder(private val view: VerseItem) : ItemHolder(view) {
lVerseNumber.setTextColor(selectedTextColor)
}
+ // audio highlight
+
+ val isNight = Preferences.getBoolean(Prefkey.is_night_mode, false)
+
+ view.background = if (highlightColor != 0) {
+ val bgRes = if (isNight) R.drawable.border_bg_night else R.drawable.border_bg
+ ContextCompat.getDrawable(view.context, bgRes)
+ } else {
+ null
+ }
+
val attributeView = view.attributeView
attributeView.setScale(scaleForAttributeView(S.applied().fontSize2dp * ui.textSizeMult))
attributeView.bookmarkCount = data.versesAttributes.bookmarkCountMap_[index]
@@ -731,6 +746,26 @@ class VersesAdapter(
return data.itemCount
}
+ //audio highlight
+
+ private val highlightedVerses = mutableMapOf()
+
+ fun updateHighlight(verseNumber: Int, color: Int) {
+ highlightedVerses[verseNumber] = color
+
+ val position = data.getPositionIgnoringPericopeFromVerse(verseNumber + 1)
+
+ if (position != -1) {
+ notifyItemChanged(position)
+ AppLog.d(TAG, "updateHighlight → verse=$verseNumber, position=$position, color=$color")
+ } else {
+ // fallback: jika tidak ketemu posisi (misalnya karena layout baru belum siap)
+ notifyDataSetChanged()
+ AppLog.w(TAG, "updateHighlight → posisi ayat $verseNumber tidak ditemukan, fallback ke notifyDataSetChanged()")
+ }
+ }
+
+
/**
* Id assignment for nice animation, keeping verses animated.
* For verses, it is always verse_1 * 1000
@@ -774,7 +809,11 @@ class VersesAdapter(
when (holder) {
is VerseTextHolder -> {
val index = data.getVerse_0(position)
- 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")
+
+ holder.bind(data, ui, listeners, attention, isChecked(position), { toggleChecked(it) }, index, highlightColor)
}
is PericopeHolder -> {
diff --git a/Alkitab/src/main/java/yuku/alkitab/base/widget/LeftDrawer.java b/Alkitab/src/main/java/yuku/alkitab/base/widget/LeftDrawer.java
index 0c39ce753..86e68914c 100644
--- a/Alkitab/src/main/java/yuku/alkitab/base/widget/LeftDrawer.java
+++ b/Alkitab/src/main/java/yuku/alkitab/base/widget/LeftDrawer.java
@@ -125,6 +125,12 @@ protected void onFinishInflate() {
});
}
+ void stopAudio() {
+ if (getContext() instanceof IsiActivity) {
+ ((IsiActivity) getContext()).getAudioPlaybackManager().stopAllAudio();
+ }
+ }
+
void setDrawerItemSelected(@NonNull TextView drawerItem) {
final int selectedTextColor = ResourcesCompat.getColor(getResources(), R.color.accent, getContext().getTheme());
drawerItem.setTextColor(selectedTextColor);
@@ -153,10 +159,12 @@ public void closeDrawer() {
}
void bHelp_click() {
+ stopAudio();
activity.startActivity(AboutActivity.createIntent());
}
void bSettings_click() {
+ stopAudio();
activity.startActivity(SettingsActivity.createIntent());
}
@@ -167,6 +175,8 @@ void bSettings_click() {
* and then starts {@link yuku.alkitab.base.ac.ReadingPlanActivity}.
*/
void bReadingPlan_click() {
+ stopAudio();
+
if (getContext() instanceof IsiActivity) {
activity.startActivity(ReadingPlanActivity.createIntent());
} else {
@@ -185,6 +195,8 @@ void bReadingPlan_click() {
* and then starts {@link SongViewActivity}.
*/
void bSongs_click() {
+ stopAudio();
+
if (getContext() instanceof IsiActivity) {
activity.startActivity(SongViewActivity.createIntent());
} else {
@@ -203,7 +215,9 @@ void bSongs_click() {
* and then starts {@link yuku.alkitab.base.ac.DevotionActivity}.
*/
void bDevotion_click() {
- if (getContext() instanceof IsiActivity) {
+ stopAudio();
+
+ if (getContext() instanceof IsiActivity) {
activity.startActivity(DevotionActivity.createIntent());
} else {
final Intent baseIntent = IsiActivity.createIntent();
@@ -339,11 +353,13 @@ protected void onFinishInflate() {
}
bMarkers.setOnClickListener(v -> {
+ stopAudio();
listener.bMarkers_click();
closeDrawer();
});
bDisplay.setOnClickListener(v -> {
+ stopAudio();
listener.bDisplay_click();
closeDrawer();
});
diff --git a/Alkitab/src/main/java/yuku/alkitab/songs/ExoplayerController.kt b/Alkitab/src/main/java/yuku/alkitab/songs/ExoplayerController.kt
index dfcda0974..da68a4a8e 100644
--- a/Alkitab/src/main/java/yuku/alkitab/songs/ExoplayerController.kt
+++ b/Alkitab/src/main/java/yuku/alkitab/songs/ExoplayerController.kt
@@ -1,12 +1,16 @@
package yuku.alkitab.songs
import android.content.Context
+import android.os.Looper
import android.text.TextUtils
+import android.util.Log
import androidx.annotation.OptIn
+import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlaybackException
import androidx.media3.exoplayer.ExoPlayer
@@ -14,15 +18,26 @@ import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.RenderersFactory
import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
+import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.extractor.mp3.Mp3Extractor
import com.afollestad.materialdialogs.MaterialDialog
import java.io.IOException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
import yuku.alkitab.base.connection.Connections
+import yuku.alkitab.base.model.MTiming
import yuku.alkitab.base.util.AppLog
+import yuku.alkitab.base.util.TimingUtil
+import yuku.alkitab.base.util.toIntArray
import yuku.alkitab.debug.R
+import yuku.alkitab.util.IntArrayList
private const val TAG = "ExoplayerController"
@@ -30,8 +45,22 @@ private const val TAG = "ExoplayerController"
* We will use [MidiController] for MIDI files.
*/
@OptIn(UnstableApi::class)
-class ExoplayerController(appContext: Context) : MediaController() {
- private val mp by lazy {
+class ExoplayerController(
+ appContext: Context,
+ private val scope: CoroutineScope
+) : MediaController() {
+ private lateinit var timingUtil: TimingUtil
+ private var isAudioBarVisible = false
+ private var audioUrl0: String? = null
+ private var audioUrl1: String? = null
+
+ private var mediaSource0: MediaSource? = null
+ private var mediaSource1: MediaSource? = null
+ private var callback: ExoplayerCallback? = null
+ var controllerId: Int = 0
+ private var currentPlayJob: Job? = null
+
+ val mp by lazy {
val audioOnlyRenderersFactory = RenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
arrayOf(
MediaCodecAudioRenderer(
@@ -102,6 +131,7 @@ class ExoplayerController(appContext: Context) : MediaController() {
private val playerListener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
+ AppLog.d(TAG, "🎬 onPlaybackStateChanged triggered: $playbackState")
when (playbackState) {
Player.STATE_READY -> {
// only start playing if the current state is preparing, i.e., not error or reset.
@@ -109,19 +139,51 @@ class ExoplayerController(appContext: Context) : MediaController() {
if (state == State.preparing) {
mp.playWhenReady = true
state = State.playing
+ callback?.onPlayerStateChanged(true)
+ }
+
+ scope.launch {
+ var lastPos = -1L
+ while (state == State.playing) {
+ delay(1000)
+ val pos = mp.currentPosition
+ val dur = mp.duration
+ AppLog.d(TAG, "🎧 Posisi: $pos / durasi: $dur repeat=${mp.repeatMode}")
+
+ // Jika durasi diketahui dan posisi sudah di akhir
+ if (dur > 0 && pos >= dur - 1000) {
+ AppLog.d(TAG, "✅ Playback mencapai akhir, panggil manual onAudioEnded()")
+ callback?.onAudioEnded()
+ break
+ }
+
+ // Jika posisi tidak berubah → kemungkinan playback macet / selesai
+ if (pos == lastPos) {
+ AppLog.d(TAG, "⚠️ Playback berhenti tanpa STATE_ENDED — panggil manual onAudioEnded()")
+ callback?.onAudioEnded()
+ break
+ }
+ lastPos = pos
+ }
}
}
Player.STATE_ENDED -> {
AppLog.d(TAG, "@@onPlayerStateChanged STATE_ENDED repeatMode=" + mp.repeatMode)
state = State.complete
+ callback?.onPlayerStateChanged(false)
+ callback?.onAudioEnded()
}
- else -> {
- }
+ else -> { }
}
}
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ AppLog.d(TAG, "onIsPlayingChanged: $isPlaying")
+ callback?.onPlayerStateChanged(isPlaying) // 🔔 Beri tahu Activity
+ }
+
override fun onPlayerError(error: PlaybackException) {
AppLog.e(TAG, "@@onPlayerError error=$error")
val activity = activityRef?.get()
@@ -149,15 +211,18 @@ class ExoplayerController(appContext: Context) : MediaController() {
}
}
state = State.error
+ callback?.onPlayerStateChanged(false)
}
}
- private fun mediaPlayerPrepare(url: String, playInLoop: Boolean) {
+ fun mediaPlayerPrepare(url: String, playInLoop: Boolean) {
try {
state = State.preparing
mp.addListener(playerListener)
+ AppLog.d(TAG, "mediaPlayerPrepare() - addListener dipanggil, url=$url")
+
mp.repeatMode = if (playInLoop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
mp.setMediaItem(MediaItem.fromUri(url))
mp.prepare()
@@ -170,25 +235,173 @@ class ExoplayerController(appContext: Context) : MediaController() {
/**
* @return current position and duration in ms. Any of them can be -1 if unknown.
*/
- override fun getProgress(): LongArray = when (state) {
- State.playing, State.paused, State.complete -> {
- val position = try {
- mp.currentPosition
- } catch (e: Exception) {
- AppLog.e(TAG, "@@getProgress getCurrentPosition", e)
- -1L
+ override fun getProgress(): LongArray {
+ return try {
+ var position = -1L
+ var duration = -1L
+
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ position = mp.currentPosition
+ duration = mp.duration
+ } else {
+ runBlocking(Dispatchers.Main) {
+ position = mp.currentPosition
+ duration = mp.duration
+ }
+ }
+
+ longArrayOf(position, duration)
+ } catch (e: Exception) {
+ AppLog.e(TAG, "@@getProgress error", e)
+ longArrayOf(-1, -1)
+ }
+ }
+
+ // === NEW FEATURE: SINGLE & MULTI AUDIO HANDLING ===
+
+ fun setCallback(callback: ExoplayerCallback) {
+ this.callback = callback
+ AppLog.d(TAG, "✅ setCallback: callback assigned = ${callback.javaClass.simpleName}")
+ }
+
+ fun initTimingUtil(timingUtil: TimingUtil) {
+ this.timingUtil = timingUtil
+ AppLog.d(TAG, "initTimingUtil() - TimingUtil assigned")
+ }
+
+ fun setAudioUrl(url: String) {
+ val source = createMediaSource(url)
+ if (controllerId == 0) {
+ audioUrl0 = url
+ mediaSource0 = source
+ AppLog.d(TAG, "[0] setAudioUrl() - URL0 set to: $url")
+ } else {
+ audioUrl1 = url
+ mediaSource1 = source
+ AppLog.d(TAG, "[$controllerId] setAudioUrl() - URL1 set to: $url")
+ }
+ }
+
+ private fun createMediaSource(url: String): MediaSource {
+ val uri = url.toUri()
+ val dataSourceFactory = DefaultHttpDataSource.Factory()
+ .setUserAgent(Connections.httpUserAgent)
+
+ // Semua URL akan menggunakan ProgressiveMediaSource
+ return ProgressiveMediaSource.Factory(dataSourceFactory)
+ .createMediaSource(MediaItem.fromUri(uri))
+ }
+
+
+ fun setAudioBarVisible(visible: Boolean) {
+ isAudioBarVisible = visible
+ }
+
+ // === PLAYBACK SPEED SUPPORT ===
+ fun setPlaybackSpeed(speed: Float) {
+ mp.playbackParameters = mp.playbackParameters.withSpeed(speed)
+ }
+
+ fun getPlaybackSpeed(): Float = mp.playbackParameters.speed
+
+ // == HIGHLIGHT VERSE ==
+ fun highlightVerses(selectedVerses: IntArrayList, color: Int) {
+ AppLog.d(TAG, "highlightVerses called - selectedVerses: $selectedVerses color: $color")
+ for (verse in selectedVerses.toIntArray()) {
+ callback?.onHighlightVerse(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(viewId)?.adapter as? VersesAdapter)
+ ?.updateHighlight(verseNumber, color)
}
+ }
+ }*/
- val duration = try {
- mp.duration
+ // == PLAY FROM SPESIFIC VERSE ==
+
+ fun playFromVerse(selectedVerse: Int, timingList:List, withEnd: Boolean = false) {
+ val verseTiming = timingList.find { it.verseNumber == selectedVerse }
+ if (verseTiming == null) {
+ Log.e(TAG, "playFromVerse - Selected verse not found in timing list")
+ return
+ }
+
+ val (startTime, endTime) = verseTiming
+ val duration = endTime - startTime
+
+ Log.d(TAG, "playFromVerse - startTime: $startTime, endTime: $endTime, selectedVerse: $selectedVerse")
+
+ currentPlayJob?.cancel()
+
+ currentPlayJob = scope.launch {
+ seekTo(startTime)
+ playOrPause(false)
+
+ if (withEnd) {
+ delay(duration)
+ callback?.onAudioEnded()
+ Log.d(TAG, "Triggering onAudioEnded() manually after delay")
+ }
+ }
+ }
+
+ fun seekTo(position: Long) {
+ mp.seekTo(position)
+ Log.d(TAG, "seekTo: $position")
+ }
+
+ fun playSegment(startTime: Long, endTime: Long, onComplete: () -> Unit) {
+ currentPlayJob?.cancel()
+ currentPlayJob = scope.launch {
+ try {
+ AppLog.d(TAG, "[$controllerId] playSegment start=$startTime end=$endTime state=$state isPlaying=${mp.isPlaying}")
+
+ if (state == State.reset || state == State.error || !mp.isPlaying) {
+ AppLog.d(TAG, "playSegment: state=$state → prepare player sebelum seekTo")
+ val url = url
+ if (url != null) {
+ mediaPlayerPrepare(url, false)
+ } else {
+ AppLog.e(TAG, "URL null saat prepare untuk segment")
+ return@launch
+ }
+ }
+
+ seekTo(startTime)
+ playOrPause(false)
+ delay(endTime - startTime)
+ playOrPause(false)
+ AppLog.d(TAG, "[$controllerId] ⏹️ playSegment selesai")
+ onComplete()
} catch (e: Exception) {
- AppLog.e(TAG, "@@getProgress getDuration", e)
- -1L
+ AppLog.e(TAG, "[$controllerId] playSegment error: ${e.message}")
+ playOrPause(false)
+ onComplete()
}
+ }
+ }
- longArrayOf(position, duration)
+ fun stopSegment() {
+ AppLog.d(TAG, "[$controllerId] stopSegment() dipanggil")
+ currentPlayJob?.cancel()
+ if (mp.isPlaying) {
+ mp.pause()
}
+ state = State.paused
+ callback?.onPlayerStateChanged(false)
+ }
- else -> longArrayOf(-1, -1)
+ interface ExoplayerCallback {
+ fun onPlayerStateChanged(isPlaying: Boolean)
+ fun onAudioEnded()
+ fun onHighlightVerse(verseNumber: Int, color: Int)
}
+
+ // === END NEW FEATURE ===
}
diff --git a/Alkitab/src/main/java/yuku/alkitab/songs/SongViewActivity.kt b/Alkitab/src/main/java/yuku/alkitab/songs/SongViewActivity.kt
index d05055abf..37099e9b0 100644
--- a/Alkitab/src/main/java/yuku/alkitab/songs/SongViewActivity.kt
+++ b/Alkitab/src/main/java/yuku/alkitab/songs/SongViewActivity.kt
@@ -10,6 +10,7 @@ import android.os.Message
import android.text.InputType
import android.text.TextUtils
import android.text.style.RelativeSizeSpan
+import androidx.lifecycle.lifecycleScope
import android.view.Menu
import android.view.MenuItem
import android.view.View
@@ -241,6 +242,8 @@ class SongViewActivity : BaseLeftDrawerActivity(), SongFragment.ShouldOverrideUr
setCustomProgressBarIndeterminateVisible(false)
+ exoplayerController = ExoplayerController(App.context, lifecycleScope)
+
drawerLayout = findViewById(R.id.drawerLayout)
leftDrawer = findViewById(R.id.left_drawer)
leftDrawer.configure(this, drawerLayout)
@@ -382,7 +385,7 @@ class SongViewActivity : BaseLeftDrawerActivity(), SongFragment.ShouldOverrideUr
if (response.contains("extension=mid")) {
setActiveMediaController(midiController)
} else {
- setActiveMediaController(exoplayerController)
+ exoplayerController?.let { setActiveMediaController(it) }
}
activeMediaController?.mediaKnownToExist(url)
} else {
@@ -613,8 +616,8 @@ class SongViewActivity : BaseLeftDrawerActivity(), SongFragment.ShouldOverrideUr
if (song.title_original != null) sb.append('(').append(song.title_original).append(')').append('\n')
sb.append('\n')
- if (song.authors_lyric != null && song.authors_lyric.size > 0) sb.append(TextUtils.join("; ", song.authors_lyric)).append('\n')
- if (song.authors_music != null && song.authors_music.size > 0) sb.append(TextUtils.join("; ", song.authors_music)).append('\n')
+ if (song.authors_lyric != null && song.authors_lyric.isNotEmpty()) sb.append(TextUtils.join("; ", song.authors_lyric)).append('\n')
+ if (song.authors_music != null && song.authors_music.isNotEmpty()) sb.append(TextUtils.join("; ", song.authors_music)).append('\n')
if (song.tune != null) sb.append(song.tune.uppercase()).append('\n')
sb.append('\n')
@@ -1098,7 +1101,7 @@ class SongViewActivity : BaseLeftDrawerActivity(), SongFragment.ShouldOverrideUr
var activeMediaController: MediaController? = null
val midiController = MidiController()
- val exoplayerController = ExoplayerController(App.context)
+ var exoplayerController: ExoplayerController? = null
var audioDisclaimerAcknowledged = false
diff --git a/Alkitab/src/main/res/drawable/border_bg.xml b/Alkitab/src/main/res/drawable/border_bg.xml
new file mode 100644
index 000000000..cddff388f
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/border_bg.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Alkitab/src/main/res/drawable/border_bg_night.xml b/Alkitab/src/main/res/drawable/border_bg_night.xml
new file mode 100644
index 000000000..e4f9c2abc
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/border_bg_night.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Alkitab/src/main/res/drawable/forward_10_24px.xml b/Alkitab/src/main/res/drawable/forward_10_24px.xml
new file mode 100644
index 000000000..8c96f8482
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/forward_10_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_audio.xml b/Alkitab/src/main/res/drawable/ic_audio.xml
new file mode 100644
index 000000000..2cbc29b40
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_audio.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_audio_night.xml b/Alkitab/src/main/res/drawable/ic_audio_night.xml
new file mode 100644
index 000000000..d48b895ae
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_audio_night.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_forward.xml b/Alkitab/src/main/res/drawable/ic_forward.xml
new file mode 100644
index 000000000..8f06c552f
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_forward.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_fullscreen.xml b/Alkitab/src/main/res/drawable/ic_fullscreen.xml
new file mode 100644
index 000000000..4670faa3b
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_fullscreen.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_fullscreen_exit.xml b/Alkitab/src/main/res/drawable/ic_fullscreen_exit.xml
new file mode 100644
index 000000000..60fa4c4fe
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_fullscreen_exit.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_next.xml b/Alkitab/src/main/res/drawable/ic_next.xml
new file mode 100644
index 000000000..fa5b7a7e5
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_next.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_next_chapter.xml b/Alkitab/src/main/res/drawable/ic_next_chapter.xml
new file mode 100644
index 000000000..cd9dde497
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_next_chapter.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_pause.xml b/Alkitab/src/main/res/drawable/ic_pause.xml
new file mode 100644
index 000000000..a8f5097f8
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_pause.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_play.xml b/Alkitab/src/main/res/drawable/ic_play.xml
new file mode 100644
index 000000000..2f84f858a
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_play.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_prev.xml b/Alkitab/src/main/res/drawable/ic_prev.xml
new file mode 100644
index 000000000..828581992
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_prev.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_prev_disabled.xml b/Alkitab/src/main/res/drawable/ic_prev_disabled.xml
new file mode 100644
index 000000000..b7340f23b
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_prev_disabled.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_prev_selector.xml b/Alkitab/src/main/res/drawable/ic_prev_selector.xml
new file mode 100644
index 000000000..6ec4d599e
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_prev_selector.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/Alkitab/src/main/res/drawable/ic_repeat.xml b/Alkitab/src/main/res/drawable/ic_repeat.xml
new file mode 100644
index 000000000..3ff08c0e5
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_repeat.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_rewind.xml b/Alkitab/src/main/res/drawable/ic_rewind.xml
new file mode 100644
index 000000000..cb92d97b4
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_rewind.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_speed.xml b/Alkitab/src/main/res/drawable/ic_speed.xml
new file mode 100644
index 000000000..d7b28d6ed
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_speed.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_video.xml b/Alkitab/src/main/res/drawable/ic_video.xml
new file mode 100644
index 000000000..2948a84c5
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_video.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/drawable/ic_video_night.xml b/Alkitab/src/main/res/drawable/ic_video_night.xml
new file mode 100644
index 000000000..1fb675520
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/ic_video_night.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/drawable/replay_10_24px.xml b/Alkitab/src/main/res/drawable/replay_10_24px.xml
new file mode 100644
index 000000000..5b02f73c4
--- /dev/null
+++ b/Alkitab/src/main/res/drawable/replay_10_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Alkitab/src/main/res/layout/activity_audio.xml b/Alkitab/src/main/res/layout/activity_audio.xml
new file mode 100644
index 000000000..8ba56db46
--- /dev/null
+++ b/Alkitab/src/main/res/layout/activity_audio.xml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Alkitab/src/main/res/layout/activity_goto.xml b/Alkitab/src/main/res/layout/activity_goto.xml
index 8d858cb2e..c392047e9 100644
--- a/Alkitab/src/main/res/layout/activity_goto.xml
+++ b/Alkitab/src/main/res/layout/activity_goto.xml
@@ -2,7 +2,8 @@
+ android:orientation="vertical"
+ android:fitsSystemWindows="true">
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true">
diff --git a/Alkitab/src/main/res/layout/activity_isi_content.xml b/Alkitab/src/main/res/layout/activity_isi_content.xml
index b4bb70891..593725d0c 100644
--- a/Alkitab/src/main/res/layout/activity_isi_content.xml
+++ b/Alkitab/src/main/res/layout/activity_isi_content.xml
@@ -72,17 +72,56 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
diff --git a/Alkitab/src/main/res/values/strings.xml b/Alkitab/src/main/res/values/strings.xml
index 25d6cc039..c31558691 100644
--- a/Alkitab/src/main/res/values/strings.xml
+++ b/Alkitab/src/main/res/values/strings.xml
@@ -570,4 +570,12 @@
Downloads
Devotion downloads
+
+ Internet Connection Problems
+ Please connect the device to the internet to enable this feature.
+
+
+ Repeat This Chapter
+ Continue to the next Chapter
+
diff --git a/build.gradle b/build.gradle
index 3126b86ca..b294c158e 100644
--- a/build.gradle
+++ b/build.gradle
@@ -9,7 +9,7 @@ buildscript {
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.12.1'
+ classpath 'com.android.tools.build:gradle:8.13.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath 'com.google.gms:google-services:4.4.3'