Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .idea/compiler.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Alkitab/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

Expand Down
493 changes: 489 additions & 4 deletions Alkitab/src/main/java/yuku/alkitab/base/IsiActivity.kt

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions Alkitab/src/main/java/yuku/alkitab/base/model/MAudio.kt
Original file line number Diff line number Diff line change
@@ -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
)
27 changes: 27 additions & 0 deletions Alkitab/src/main/java/yuku/alkitab/base/model/MTiming.kt
Original file line number Diff line number Diff line change
@@ -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<MTiming> {
val timestampsArray = JSONObject(jsonText).getJSONArray("timestamps")
return List(timestampsArray.length()) { i ->
fromJson(timestampsArray.getJSONObject(i))
}
}
}
}

Choose a reason for hiding this comment

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

medium

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

323 changes: 323 additions & 0 deletions Alkitab/src/main/java/yuku/alkitab/base/util/AudioPlaybackManager.kt
Original file line number Diff line number Diff line change
@@ -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<Book, Int>? {
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<MTiming>,
list1: List<MTiming>,
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
}

}
Loading