Polish audio/TTS UI: move labels to strings.xml, fix TTS WorkManager observer leak

This commit is contained in:
Atte149 2026-06-16 19:57:52 +03:00
parent ccfc677ea7
commit 6ddbb01973
4 changed files with 76 additions and 33 deletions

View file

@ -8,6 +8,8 @@ package org.dueattendant149.bookshelf.presentation.tts
import android.app.Application import android.app.Application
import android.util.Log import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.work.Constraints import androidx.work.Constraints
@ -29,6 +31,7 @@ import org.dueattendant149.bookshelf.domain.use_case.tts.CreateTtsJobUseCase
import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsEnginesUseCase import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsEnginesUseCase
import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsVoicesUseCase import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsVoicesUseCase
import org.dueattendant149.bookshelf.domain.use_case.tts.GetTtsJobUseCase import org.dueattendant149.bookshelf.domain.use_case.tts.GetTtsJobUseCase
import org.dueattendant149.bookshelf.R
import javax.inject.Inject import javax.inject.Inject
data class TtsState( data class TtsState(
@ -60,6 +63,9 @@ class TtsModel
private val workManager = WorkManager.getInstance(application) private val workManager = WorkManager.getInstance(application)
private var observedWork: LiveData<WorkInfo?>? = null
private var workObserver: Observer<WorkInfo?>? = null
fun load(bookRemoteId: String) { fun load(bookRemoteId: String) {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null) }
@ -123,7 +129,7 @@ class TtsModel
speed = _state.value.speed, speed = _state.value.speed,
) )
val job = result.getOrElse { val job = result.getOrElse {
_state.update { it.copy(isCreatingJob = false, error = it.error ?: "Failed to create TTS job") } _state.update { it.copy(isCreatingJob = false, error = it.error ?: application.getString(R.string.tts_create_job_failed)) }
return@launch return@launch
} }
_state.update { it.copy(isCreatingJob = false, job = job) } _state.update { it.copy(isCreatingJob = false, job = job) }
@ -168,32 +174,47 @@ class TtsModel
} }
private fun observeWorker(workId: java.util.UUID) { private fun observeWorker(workId: java.util.UUID) {
workManager.getWorkInfoByIdLiveData(workId) workObserver?.let { observer ->
.observeForever { info: WorkInfo? -> observedWork?.removeObserver(observer)
info ?: return@observeForever }
when (info.state) {
WorkInfo.State.SUCCEEDED -> {
val filePath = info.outputData.getString(TtsDownloadWorker.KEY_OUTPUT_FILE_PATH)
_state.update { it.copy(outputFilePath = filePath) }
}
WorkInfo.State.FAILED -> { val liveData = workManager.getWorkInfoByIdLiveData(workId)
val message = info.outputData.getString(TtsDownloadWorker.KEY_ERROR_MESSAGE) val observer: Observer<WorkInfo?> = Observer { info ->
_state.update { it.copy(error = message ?: "TTS download failed") } info ?: return@Observer
} when (info.state) {
WorkInfo.State.SUCCEEDED -> {
WorkInfo.State.RUNNING -> { val filePath = info.outputData.getString(TtsDownloadWorker.KEY_OUTPUT_FILE_PATH)
val progress = info.progress.getDouble(TtsDownloadWorker.PROGRESS_PROGRESS, 0.0) _state.update { it.copy(outputFilePath = filePath) }
val status = info.progress.getString(TtsDownloadWorker.PROGRESS_STATUS) ?: ""
_state.update {
it.copy(
job = it.job?.copy(status = status, progress = progress)
)
}
}
else -> {}
} }
WorkInfo.State.FAILED -> {
val message = info.outputData.getString(TtsDownloadWorker.KEY_ERROR_MESSAGE)
_state.update { it.copy(error = message ?: application.getString(R.string.tts_download_failed)) }
}
WorkInfo.State.RUNNING -> {
val progress = info.progress.getDouble(TtsDownloadWorker.PROGRESS_PROGRESS, 0.0)
val status = info.progress.getString(TtsDownloadWorker.PROGRESS_STATUS) ?: ""
_state.update {
it.copy(
job = it.job?.copy(status = status, progress = progress)
)
}
}
else -> {}
} }
}
observedWork = liveData
workObserver = observer
liveData.observeForever(observer)
}
override fun onCleared() {
workObserver?.let { observer ->
observedWork?.removeObserver(observer)
}
super.onCleared()
} }
} }

View file

@ -41,10 +41,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.presentation.player.PlayerState import org.dueattendant149.bookshelf.presentation.player.PlayerState
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -73,7 +75,7 @@ fun PlayerContent(
IconButton(onClick = { navigator.pop() }) { IconButton(onClick = { navigator.pop() }) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back" contentDescription = stringResource(R.string.go_back_content_desc)
) )
} }
} }
@ -97,7 +99,7 @@ fun PlayerContent(
val currentTrack = playerState.tracks.getOrNull(playerState.currentTrackIndex) val currentTrack = playerState.tracks.getOrNull(playerState.currentTrackIndex)
Text( Text(
text = currentTrack?.title ?: "No track", text = currentTrack?.title ?: stringResource(R.string.player_no_track),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -145,10 +147,10 @@ fun PlayerContent(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onSeekBackward) { IconButton(onClick = onSeekBackward) {
Text("-10s") Text(stringResource(R.string.player_seek_backward))
} }
IconButton(onClick = onSkipPrevious) { IconButton(onClick = onSkipPrevious) {
Icon(Icons.Default.SkipPrevious, contentDescription = "Previous") Icon(Icons.Default.SkipPrevious, contentDescription = stringResource(R.string.player_previous))
} }
IconButton( IconButton(
onClick = onPlayPause, onClick = onPlayPause,
@ -156,15 +158,17 @@ fun PlayerContent(
) { ) {
Icon( Icon(
imageVector = if (playerState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, imageVector = if (playerState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (playerState.isPlaying) "Pause" else "Play", contentDescription = stringResource(
if (playerState.isPlaying) R.string.player_pause else R.string.player_play
),
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) )
} }
IconButton(onClick = onSkipNext) { IconButton(onClick = onSkipNext) {
Icon(Icons.Default.SkipNext, contentDescription = "Next") Icon(Icons.Default.SkipNext, contentDescription = stringResource(R.string.player_next))
} }
IconButton(onClick = onSeekForward) { IconButton(onClick = onSeekForward) {
Text("+30s") Text(stringResource(R.string.player_seek_forward))
} }
} }

View file

@ -226,7 +226,15 @@ private fun VoiceItem(
) { ) {
ListItem( ListItem(
headlineContent = { Text(voice.name) }, headlineContent = { Text(voice.name) },
supportingContent = { Text("${voice.language}${voice.engine}") }, supportingContent = {
Text(
stringResource(
R.string.tts_voice_description,
voice.language,
voice.engine
)
)
},
trailingContent = { trailingContent = {
if (selected) { if (selected) {
Text(stringResource(R.string.tts_selected_label)) Text(stringResource(R.string.tts_selected_label))

View file

@ -12,6 +12,13 @@
<!-- Audio playback --> <!-- Audio playback -->
<string name="audio_playback_channel">Audio playback</string> <string name="audio_playback_channel">Audio playback</string>
<string name="audio_playback_channel_description">Shows the currently playing audiobook</string> <string name="audio_playback_channel_description">Shows the currently playing audiobook</string>
<string name="player_no_track">No track</string>
<string name="player_seek_backward">-10s</string>
<string name="player_seek_forward">+30s</string>
<string name="player_previous">Previous</string>
<string name="player_next">Next</string>
<string name="player_play">Play</string>
<string name="player_pause">Pause</string>
<!-- Screens --> <!-- Screens -->
<string name="library_screen">Library</string> <string name="library_screen">Library</string>
@ -569,5 +576,8 @@
<string name="tts_speed_label">Speed: %1$.2fx</string> <string name="tts_speed_label">Speed: %1$.2fx</string>
<string name="tts_create_job_button">Create TTS job</string> <string name="tts_create_job_button">Create TTS job</string>
<string name="tts_book_id_label">Book: %1$s</string> <string name="tts_book_id_label">Book: %1$s</string>
<string name="tts_voice_description">%1$s • %2$s</string>
<string name="tts_create_job_failed">Failed to create TTS job</string>
<string name="tts_download_failed">TTS download failed</string>
</resources> </resources>