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'