Skip to content

Commit

Permalink
android: use media3 instead of standard media-player; fix number bugs…
Browse files Browse the repository at this point in the history
… with playing audio and with stage options
  • Loading branch information
sszuev committed Jan 20, 2025
1 parent 1f55a84 commit 5587309
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 150 deletions.
3 changes: 2 additions & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ plugins {

android {
namespace = "com.github.sszuev.flashcards.android"
compileSdk = 34
compileSdk = 35

defaultConfig {
applicationId = "com.github.sszuev.flashcards.android"
Expand Down Expand Up @@ -54,6 +54,7 @@ dependencies {
implementation(libs.androidx.splashscreen)
implementation(libs.androidx.browser)
implementation(libs.androidx.compose.icons.extended)
implementation(libs.androidx.media3)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class MainActivity : ComponentActivity() {
}
private val cardViewModel: CardViewModel by viewModels {
CardsViewModelFactory(
context = application,
cardsRepository = CardsRepository(AppConfig.serverUri),
ttsRepository = TTSRepository(AppConfig.serverUri),
translationRepository = TranslationRepository(AppConfig.serverUri),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.github.sszuev.flashcards.android.models

import android.media.MediaPlayer
import android.app.Application
import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.State
Expand All @@ -9,7 +9,10 @@ import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.sszuev.flashcards.android.PLAY_AUDIO_EMERGENCY_TIMEOUT_MS
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import com.github.sszuev.flashcards.android.entities.CardEntity
import com.github.sszuev.flashcards.android.repositories.CardsRepository
import com.github.sszuev.flashcards.android.repositories.InvalidTokenException
Expand All @@ -28,6 +31,7 @@ import kotlin.io.path.deleteIfExists
import kotlin.io.path.outputStream

class CardViewModel(
private val context: Application,
private val cardsRepository: CardsRepository,
private val ttsRepository: TTSRepository,
private val translationRepository: TranslationRepository,
Expand All @@ -49,21 +53,6 @@ class CardViewModel(
val sortField: State<String?> = _sortField
private val _isAscending = mutableStateOf(true)
val isAscending: State<Boolean> = _isAscending

@Suppress("PrivatePropertyName")
private val NULL_AUDIO_MARKER = ByteArray(0)
private val _audioResources = object : LruCache<String, ByteArray?>(1024) {
fun getNullable(key: String): ByteArray? {
return when (val result = get(key)) {
NULL_AUDIO_MARKER -> null
else -> result
}
}

fun putNullable(key: String, value: ByteArray?) {
put(key, value ?: NULL_AUDIO_MARKER)
}
}
private val _isAudioLoading = mutableStateMapOf<String, Boolean>()
private val _isAudioPlaying = mutableStateMapOf<String, Boolean>()

Expand Down Expand Up @@ -93,6 +82,37 @@ class CardViewModel(
private val _additionalCardsDeck = mutableStateOf<List<CardEntity>>(emptyList())
val additionalCardsDeck: State<List<CardEntity>> = _additionalCardsDeck

private val activeMediaPlayers = mutableMapOf<String, ExoPlayer>()

private val _audioResources = object : LruCache<String, ByteArray?>(1024) {
private val NULL_AUDIO_MARKER = ByteArray(0)

@Synchronized
fun getNullable(key: String): ByteArray? {
return when (val result = get(key)) {
NULL_AUDIO_MARKER -> null
else -> result
}
}

@Synchronized
fun putNullable(key: String, value: ByteArray?) {
put(key, value ?: NULL_AUDIO_MARKER)
}

@Synchronized
fun hasKey(key: String): Boolean {
return super.get(key) != null
}

@Suppress("ReplaceArrayEqualityOpWithArraysEquals")
@Synchronized
fun hasKeyAndValue(key: String): Boolean {
val res = super.get(key)
return res != null && res != NULL_AUDIO_MARKER
}
}

val selectedCard: CardEntity?
get() = if (_selectedCardId.value == null) null else {
_cards.value.singleOrNull { it.cardId == _selectedCardId.value }
Expand Down Expand Up @@ -284,7 +304,7 @@ class CardViewModel(
random = true,
unknown = true,
length = length,
)
).distinct()
}.map { it.toCardEntity() }
if (cards.isEmpty()) {
_errorMessage.value = "No cards available in the selected dictionaries."
Expand Down Expand Up @@ -316,7 +336,7 @@ class CardViewModel(
random = true,
unknown = false,
length = length,
)
).distinct()
}.map { it.toCardEntity() }
if (cards.isEmpty()) {
_errorMessage.value = "No cards available in the selected dictionaries."
Expand All @@ -333,6 +353,15 @@ class CardViewModel(
}
}

fun waitForAudioToFinish(cardId: String, onComplete: () -> Unit) {
viewModelScope.launch {
while (isAudioLoading(cardId) || isAudioPlaying(cardId)) {
delay(100)
}
onComplete()
}
}

fun loadAndPlayAudio(card: CardEntity) {
val cardId = checkNotNull(card.cardId)
if (isAudioPlaying(cardId)) {
Expand All @@ -342,6 +371,7 @@ class CardViewModel(
)
return
}
stopAllAudio()
if (isAudioLoaded(cardId)) {
playAudio(card)
} else {
Expand All @@ -351,20 +381,9 @@ class CardViewModel(
}
}

fun waitForAudioToFinish(cardId: String, onComplete: () -> Unit) {
viewModelScope.launch {
while (isAudioOperationInProgress(cardId)) {
delay(100)
}
onComplete()
}
}

private fun loadAudio(cardId: String, audioResourceId: String, onLoaded: () -> Unit) {
if (_audioResources.get(cardId) != null) {
if (_audioResources.getNullable(cardId) != null) {
onLoaded()
}
if (_audioResources.hasKeyAndValue(cardId)) {
onLoaded()
return
}
viewModelScope.launch {
Expand Down Expand Up @@ -398,14 +417,13 @@ class CardViewModel(
private fun playAudio(card: CardEntity) {
val cardId = checkNotNull(card.cardId)

val audioData = _audioResources.get(cardId)
@Suppress("ReplaceArrayEqualityOpWithArraysEquals")
if (audioData == null || audioData == NULL_AUDIO_MARKER) {
val audioData = _audioResources.getNullable(cardId)
if (audioData == null) {
Log.d(tag, "playAudion: no audio data for [cardId = $cardId (${card.word})]")
return
}

if (_isAudioPlaying.putIfAbsent(cardId, true) == true) {
if (setAudioIsPlaying(cardId)) {
Log.d(tag, "playAudion: audio is already playing for [cardId = $cardId (${card.word})]")
return
}
Expand All @@ -428,63 +446,40 @@ class CardViewModel(
}

withContext(Dispatchers.Main) {
var isCompleted = false
MediaPlayer().apply {
val timeoutJob = viewModelScope.launch {
delay(PLAY_AUDIO_EMERGENCY_TIMEOUT_MS)
if (!isCompleted && !isPlaying) {
Log.w(tag, "playAudion: timeout reached. Stopping playback.")
releaseAudioResources(cardId, tempFile)
}
}
setDataSource(tempFile.toFile().absolutePath)
setOnPreparedListener {
try {
if (!isPlaying) {
val exoPlayer = ExoPlayer.Builder(context).build()

exoPlayer.apply {
val mediaItem = MediaItem.fromUri(tempFile.toUri().toString())
setMediaItem(mediaItem)
prepare()
playWhenReady = true

addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
Log.d(
tag,
"playAudio: starting playback [cardId = $cardId (${card.word})]"
"playAudio: playback completed [cardId = $cardId (${card.word})]"
)
start()
this@apply.onFinish(cardId, tempFile)
}
} catch (e: Exception) {
Log.e(
tag,
"playAudion: Error during playback start [cardId = $cardId (${card.word})]:" +
" ${e.localizedMessage}",
e
)
releaseAudioResources(cardId, tempFile)
}
}
setOnCompletionListener {
try {
Log.d(

override fun onPlayerError(error: PlaybackException) {
Log.e(
tag,
"playAudion: completed [cardId = $cardId (${card.word})]"
"playAudio: error occurred [cardId = $cardId (${card.word})]: ${error.message}",
error
)
onFinish()
isCompleted = true
} finally {
releaseAudioResources(cardId, tempFile)
timeoutJob.cancel()
}
}
setOnErrorListener { mp, _, _ ->
try {
Log.d(tag, "playAudion: error, [cardId = $cardId (${card.word})]")
mp.onFinish()
isCompleted = true
true
} finally {
releaseAudioResources(cardId, tempFile)
timeoutJob.cancel()
this@apply.onFinish(cardId, tempFile)
}
}
prepareAsync()
})

activeMediaPlayers[cardId] = this
Log.d(tag, "playAudio: start playing [cardId = $cardId (${card.word})]")
play()
}
}

} catch (e: Exception) {
Log.e(
tag,
Expand All @@ -496,40 +491,53 @@ class CardViewModel(
}
}

private fun stopAllAudio() {
activeMediaPlayers.forEach { (cardId, player) ->
Log.d(tag, "stopAudio($cardId)")
try {
player.onFinish(cardId, null)
} catch (e: Exception) {
Log.e(tag, "Error stopping audio for [cardId = $cardId]: ${e.localizedMessage}", e)
}
}
activeMediaPlayers.clear()
}


private fun ExoPlayer.onFinish(cardId: String, tempFile: Path?) {
try {
release()
} catch (_: Exception) {
}
releaseAudioResources(cardId, tempFile)
}

private fun releaseAudioResources(cardId: String, tempFile: Path?) {
viewModelScope.launch(Dispatchers.Main) {
_isAudioPlaying.remove(cardId)
activeMediaPlayers.remove(cardId)
}
viewModelScope.launch(Dispatchers.IO) {
tempFile?.deleteIfExists()
}
}

private fun MediaPlayer.onFinish() {
stop()
reset()
release()
fun isAudioPlaying(cardId: String): Boolean {
return _isAudioPlaying[cardId] ?: false
}

private fun isAudioLoaded(cardId: String): Boolean {
return _audioResources.get(cardId) != null
private fun setAudioIsPlaying(cardId: String): Boolean {
return _isAudioPlaying.putIfAbsent(cardId, true) == true
}

@Suppress("ReplaceArrayEqualityOpWithArraysEquals")
fun audioIsAvailable(cardId: String): Boolean {
return _audioResources.get(cardId) != NULL_AUDIO_MARKER
private fun isAudioLoaded(cardId: String): Boolean {
return _audioResources.hasKey(cardId)
}

fun isAudioLoading(cardId: String): Boolean {
return _isAudioLoading[cardId] ?: false
}

fun isAudioOperationInProgress(cardId: String): Boolean {
return isAudioLoading(cardId) || isAudioPlaying(cardId)
}

private fun isAudioPlaying(cardId: String): Boolean {
return _isAudioPlaying[cardId] ?: false
}

fun selectCard(cardId: String?) {
_selectedCardId.value = cardId
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.github.sszuev.flashcards.android.models

import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.github.sszuev.flashcards.android.repositories.CardsRepository
Expand All @@ -23,6 +24,7 @@ class DictionariesViewModelFactory(
}

class CardsViewModelFactory(
private val context: Application,
private val cardsRepository: CardsRepository,
private val ttsRepository: TTSRepository,
private val translationRepository: TranslationRepository,
Expand All @@ -32,7 +34,13 @@ class CardsViewModelFactory(
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(CardViewModel::class.java)) {
return CardViewModel(cardsRepository, ttsRepository, translationRepository, signOut) as T
return CardViewModel(
context = context,
cardsRepository = cardsRepository,
ttsRepository = ttsRepository,
translationRepository = translationRepository,
signOut = signOut,
) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
Expand Down
Loading

0 comments on commit 5587309

Please sign in to comment.