feat(tts): implement TTS jobs screen with bookshelf-api integration

- Add domain models, repository, and use cases for TTS engines/voices/jobs
- Add TtsDownloadWorker to poll job status and download completed audio
- Add TtsScreen/TtsModel/TtsContent with engine/voice selection and job creation
- Add TTS button to RemoteLibraryContent book cards
- Add Hilt WorkManager support and repository binding
- Add TTS string resources
This commit is contained in:
Atte149 2026-06-16 11:57:10 +03:00
parent ed3afc1f16
commit a6f8b6782a
17 changed files with 990 additions and 4 deletions

View file

@ -7,13 +7,25 @@
package org.dueattendant149.bookshelf
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import org.dueattendant149.bookshelf.core.crash.CrashHandler
import javax.inject.Inject
@HiltAndroidApp
class Application : Application() {
class Application : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override fun onCreate() {
super.onCreate()
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}

View file

@ -29,13 +29,13 @@ import org.dueattendant149.bookshelf.data.parser.file.FileParserImpl
import org.dueattendant149.bookshelf.data.parser.text.TextParser
import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl
import org.dueattendant149.bookshelf.data.repository.BookRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.RemoteTtsRepositoryImpl
import org.dueattendant149.bookshelf.domain.repository.BookRepository
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
@ -43,6 +43,7 @@ import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository
import org.dueattendant149.bookshelf.domain.repository.HistoryRepository
import org.dueattendant149.bookshelf.domain.repository.PermissionRepository
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
import javax.inject.Singleton
@Module
@ -96,6 +97,12 @@ abstract class RepositoryModule {
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
): RemoteLibraryRepository
@Binds
@Singleton
abstract fun bindRemoteTtsRepository(
remoteTtsRepositoryImpl: RemoteTtsRepositoryImpl
): RemoteTtsRepository
@Binds
@Singleton
abstract fun bindBookMapper(

View file

@ -0,0 +1,152 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.data.repository
import android.util.Log
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEngineResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoiceResponse
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RemoteTtsRepositoryImpl
@Inject
constructor(
private val clientFactory: BookshelfApiClientFactory,
private val serverSettings: ServerSettings,
) : RemoteTtsRepository {
override suspend fun fetchEngines(): Result<List<TtsEngine>> {
return runCatching {
val client = client() ?: return Result.success(emptyList())
val response = client.getTtsEngines()
if (!response.isSuccessful) {
return Result.failure(RuntimeException("Failed to fetch TTS engines: ${response.code()}"))
}
response.body()?.engines.orEmpty().map { it.toDomain() }
}.onFailure {
Log.e("RemoteTtsRepo", "fetchEngines failed", it)
}
}
override suspend fun fetchVoices(engine: String?): Result<List<TtsVoice>> {
return runCatching {
val client = client() ?: return Result.success(emptyList())
val response = client.getTtsVoices(engine)
if (!response.isSuccessful) {
return Result.failure(RuntimeException("Failed to fetch TTS voices: ${response.code()}"))
}
response.body()?.voices.orEmpty().map { it.toDomain() }
}.onFailure {
Log.e("RemoteTtsRepo", "fetchVoices failed", it)
}
}
override suspend fun createJob(
bookId: String,
engine: String,
voiceId: String,
speed: Double,
): Result<TtsJob> {
return runCatching {
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
val response = client.createTtsJob(
TtsCreateRequest(
bookId = bookId,
engine = engine,
voiceId = voiceId,
speed = speed,
)
)
if (!response.isSuccessful) {
return Result.failure(RuntimeException("Failed to create TTS job: ${response.code()}"))
}
val body = response.body() ?: return Result.failure(RuntimeException("Empty TTS job response"))
body.toDomain()
}.onFailure {
Log.e("RemoteTtsRepo", "createJob failed", it)
}
}
override suspend fun getJob(jobId: String): Result<TtsJob> {
return runCatching {
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
val response = client.getTtsJobStatus(jobId)
if (!response.isSuccessful) {
return Result.failure(RuntimeException("Failed to fetch TTS job: ${response.code()}"))
}
val body = response.body() ?: return Result.failure(RuntimeException("Empty TTS job response"))
body.toDomain()
}.onFailure {
Log.e("RemoteTtsRepo", "getJob failed", it)
}
}
override suspend fun downloadAudio(jobId: String): Result<ByteArray> {
return runCatching {
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
val response = client.downloadTtsAudio(jobId)
if (!response.isSuccessful) {
return Result.failure(RuntimeException("Failed to download TTS audio: ${response.code()}"))
}
response.body()?.bytes() ?: return Result.failure(RuntimeException("Empty TTS audio response"))
}.onFailure {
Log.e("RemoteTtsRepo", "downloadAudio failed", it)
}
}
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
private fun TtsEngineResponse.toDomain() =
TtsEngine(
id = id,
name = name,
supportsStreaming = capabilities.supportsStreaming,
supportsCloning = capabilities.supportsCloning,
maxTextLength = capabilities.maxTextLength,
needsNetwork = capabilities.needsNetwork,
)
private fun TtsVoiceResponse.toDomain() =
TtsVoice(
id = id,
name = name,
language = language,
engine = engine,
gender = gender,
quality = quality,
requiresReference = requiresReference,
)
private fun TtsJobResponse.toDomain() =
TtsJob(
jobId = jobId,
bookId = bookId,
title = title,
author = author,
engine = engine,
voiceId = voiceId,
speed = speed,
status = status,
progress = progress,
currentChapter = currentChapter,
totalChapters = totalChapters,
completedChapters = completedChapters,
outputPath = outputPath,
error = error,
createdAt = createdAt,
startedAt = startedAt,
completedAt = completedAt,
)
}

View file

@ -0,0 +1,160 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.data.worker
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.delay
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
import java.io.File
@HiltWorker
class TtsDownloadWorker
@AssistedInject
constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val remoteTtsRepository: RemoteTtsRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val jobId = inputData.getString(KEY_JOB_ID)
?: return Result.failure(errorData("Missing job ID"))
val bookId = inputData.getString(KEY_BOOK_ID) ?: ""
val maxAttempts = inputData.getInt(KEY_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS)
val pollIntervalMs = inputData.getLong(KEY_POLL_INTERVAL_MS, DEFAULT_POLL_INTERVAL_MS)
repeat(maxAttempts) { attempt ->
val result = remoteTtsRepository.getJob(jobId)
val job = result.getOrElse {
Log.e(TAG, "Failed to poll job $jobId (attempt $attempt)", it)
return Result.retry()
}
setProgress(
progressData(
jobId = jobId,
status = job.status,
progress = job.progress,
currentChapter = job.currentChapter,
completedChapters = job.completedChapters,
totalChapters = job.totalChapters,
)
)
when {
job.isCompleted -> {
val downloadResult = remoteTtsRepository.downloadAudio(jobId)
val audioBytes = downloadResult.getOrElse {
Log.e(TAG, "Failed to download audio for job $jobId", it)
return Result.failure(errorData(it.message ?: "Download failed"))
}
val outputFile = outputFile(bookId, jobId)
outputFile.parentFile?.mkdirs()
outputFile.writeBytes(audioBytes)
Log.i(TAG, "Saved TTS audio for job $jobId to ${outputFile.absolutePath}")
return Result.success(
successData(
jobId = jobId,
filePath = outputFile.absolutePath,
)
)
}
job.isFailed -> {
Log.e(TAG, "TTS job $jobId failed: ${job.error}")
return Result.failure(errorData(job.error.ifBlank { "TTS job failed" }))
}
else -> {
delay(pollIntervalMs)
}
}
}
return Result.failure(errorData("TTS job polling timed out"))
}
private fun outputFile(bookId: String, jobId: String): File {
val safeBookId = bookId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
val safeJobId = jobId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
val dir = File(applicationContext.cacheDir, "tts")
return File(dir, "${safeBookId}_${safeJobId}.mp3")
}
companion object {
const val TAG = "TtsDownloadWorker"
const val KEY_JOB_ID = "job_id"
const val KEY_BOOK_ID = "book_id"
const val KEY_MAX_ATTEMPTS = "max_attempts"
const val KEY_POLL_INTERVAL_MS = "poll_interval_ms"
const val KEY_OUTPUT_FILE_PATH = "output_file_path"
const val KEY_OUTPUT_JOB_ID = "output_job_id"
const val KEY_ERROR_MESSAGE = "error_message"
const val PROGRESS_STATUS = "progress_status"
const val PROGRESS_PROGRESS = "progress_progress"
const val PROGRESS_CURRENT_CHAPTER = "progress_current_chapter"
const val PROGRESS_COMPLETED_CHAPTERS = "progress_completed_chapters"
const val PROGRESS_TOTAL_CHAPTERS = "progress_total_chapters"
private const val DEFAULT_MAX_ATTEMPTS = 360
private const val DEFAULT_POLL_INTERVAL_MS = 5_000L
fun createInputData(
jobId: String,
bookId: String,
maxAttempts: Int = DEFAULT_MAX_ATTEMPTS,
pollIntervalMs: Long = DEFAULT_POLL_INTERVAL_MS,
): Data =
Data.Builder()
.putString(KEY_JOB_ID, jobId)
.putString(KEY_BOOK_ID, bookId)
.putInt(KEY_MAX_ATTEMPTS, maxAttempts)
.putLong(KEY_POLL_INTERVAL_MS, pollIntervalMs)
.build()
private fun progressData(
jobId: String,
status: String,
progress: Double,
currentChapter: String,
completedChapters: Int,
totalChapters: Int,
): Data =
Data.Builder()
.putString(KEY_JOB_ID, jobId)
.putString(PROGRESS_STATUS, status)
.putDouble(PROGRESS_PROGRESS, progress)
.putString(PROGRESS_CURRENT_CHAPTER, currentChapter)
.putInt(PROGRESS_COMPLETED_CHAPTERS, completedChapters)
.putInt(PROGRESS_TOTAL_CHAPTERS, totalChapters)
.build()
private fun successData(jobId: String, filePath: String): Data =
Data.Builder()
.putString(KEY_OUTPUT_JOB_ID, jobId)
.putString(KEY_OUTPUT_FILE_PATH, filePath)
.build()
private fun errorData(message: String): Data =
Data.Builder()
.putString(KEY_ERROR_MESSAGE, message)
.build()
}
}

View file

@ -0,0 +1,50 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.domain.model.tts
data class TtsEngine(
val id: String,
val name: String,
val supportsStreaming: Boolean,
val supportsCloning: Boolean,
val maxTextLength: Int,
val needsNetwork: Boolean,
)
data class TtsVoice(
val id: String,
val name: String,
val language: String,
val engine: String,
val gender: String,
val quality: String,
val requiresReference: Boolean,
)
data class TtsJob(
val jobId: String,
val bookId: String,
val title: String,
val author: String,
val engine: String,
val voiceId: String,
val speed: Double,
val status: String,
val progress: Double,
val currentChapter: String,
val totalChapters: Int,
val completedChapters: Int,
val outputPath: String,
val error: String,
val createdAt: Double,
val startedAt: Double,
val completedAt: Double,
) {
val isCompleted: Boolean get() = status == "completed"
val isFailed: Boolean get() = status == "failed" || status == "error"
val isRunning: Boolean get() = status == "running" || status == "queued" || status == "pending"
}

View file

@ -0,0 +1,25 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.domain.repository
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
interface RemoteTtsRepository {
suspend fun fetchEngines(): Result<List<TtsEngine>>
suspend fun fetchVoices(engine: String? = null): Result<List<TtsVoice>>
suspend fun createJob(
bookId: String,
engine: String,
voiceId: String,
speed: Double,
): Result<TtsJob>
suspend fun getJob(jobId: String): Result<TtsJob>
suspend fun downloadAudio(jobId: String): Result<ByteArray>
}

View file

@ -0,0 +1,24 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.domain.use_case.tts
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
import javax.inject.Inject
class CreateTtsJobUseCase
@Inject
constructor(
private val repository: RemoteTtsRepository,
) {
suspend operator fun invoke(
bookId: String,
engine: String,
voiceId: String,
speed: Double,
): Result<TtsJob> = repository.createJob(bookId, engine, voiceId, speed)
}

View file

@ -0,0 +1,19 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.domain.use_case.tts
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
import javax.inject.Inject
class FetchTtsEnginesUseCase
@Inject
constructor(
private val repository: RemoteTtsRepository,
) {
suspend operator fun invoke(): Result<List<TtsEngine>> = repository.fetchEngines()
}

View file

@ -0,0 +1,20 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.domain.use_case.tts
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
import javax.inject.Inject
class FetchTtsVoicesUseCase
@Inject
constructor(
private val repository: RemoteTtsRepository,
) {
suspend operator fun invoke(engine: String? = null): Result<List<TtsVoice>> =
repository.fetchVoices(engine)
}

View file

@ -0,0 +1,19 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.domain.use_case.tts
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
import javax.inject.Inject
class GetTtsJobUseCase
@Inject
constructor(
private val repository: RemoteTtsRepository,
) {
suspend operator fun invoke(jobId: String): Result<TtsJob> = repository.getJob(jobId)
}

View file

@ -11,7 +11,9 @@ import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.parcelize.Parcelize
import org.dueattendant149.bookshelf.presentation.navigator.Screen
import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
@Parcelize
object RemoteLibraryScreen : Screen, Parcelable {
@ -19,6 +21,12 @@ object RemoteLibraryScreen : Screen, Parcelable {
@Composable
override fun Content() {
val model = hiltViewModel<RemoteLibraryModel>()
RemoteLibraryContent(model = model)
val navigator = LocalNavigator.current
RemoteLibraryContent(
model = model,
navigateToTts = { bookRemoteId ->
navigator.push(TtsScreen(bookRemoteId))
}
)
}
}

View file

@ -0,0 +1,199 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.presentation.tts
import android.app.Application
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.dueattendant149.bookshelf.data.worker.TtsDownloadWorker
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
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.FetchTtsVoicesUseCase
import org.dueattendant149.bookshelf.domain.use_case.tts.GetTtsJobUseCase
import javax.inject.Inject
data class TtsState(
val engines: List<TtsEngine> = emptyList(),
val voices: List<TtsVoice> = emptyList(),
val selectedEngine: TtsEngine? = null,
val selectedVoice: TtsVoice? = null,
val speed: Double = 1.0,
val job: TtsJob? = null,
val isLoading: Boolean = false,
val isCreatingJob: Boolean = false,
val error: String? = null,
val outputFilePath: String? = null,
)
@HiltViewModel
class TtsModel
@Inject
constructor(
private val application: Application,
private val fetchTtsEnginesUseCase: FetchTtsEnginesUseCase,
private val fetchTtsVoicesUseCase: FetchTtsVoicesUseCase,
private val createTtsJobUseCase: CreateTtsJobUseCase,
private val getTtsJobUseCase: GetTtsJobUseCase,
) : ViewModel() {
private val _state = MutableStateFlow(TtsState())
val state = _state.asStateFlow()
private val workManager = WorkManager.getInstance(application)
fun load(bookRemoteId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val enginesResult = fetchTtsEnginesUseCase()
val engines = enginesResult.getOrDefault(emptyList())
val selectedEngine = engines.firstOrNull()
val voicesResult = selectedEngine?.let { fetchTtsVoicesUseCase(it.id) }
?: fetchTtsVoicesUseCase()
val voices = voicesResult?.getOrDefault(emptyList()) ?: emptyList()
_state.update {
it.copy(
isLoading = false,
engines = engines,
voices = voices,
selectedEngine = selectedEngine,
selectedVoice = voices.firstOrNull(),
error = enginesResult.exceptionOrNull()?.message
?: voicesResult?.exceptionOrNull()?.message,
)
}
}
}
fun selectEngine(engine: TtsEngine) {
_state.update { it.copy(selectedEngine = engine, selectedVoice = null) }
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val result = fetchTtsVoicesUseCase(engine.id)
_state.update {
it.copy(
isLoading = false,
voices = result.getOrDefault(emptyList()),
selectedVoice = result.getOrDefault(emptyList()).firstOrNull(),
error = result.exceptionOrNull()?.message,
)
}
}
}
fun selectVoice(voice: TtsVoice) {
_state.update { it.copy(selectedVoice = voice) }
}
fun updateSpeed(speed: Double) {
_state.update { it.copy(speed = speed.coerceIn(0.5, 2.0)) }
}
fun createJob(bookRemoteId: String) {
val engine = _state.value.selectedEngine ?: return
val voice = _state.value.selectedVoice ?: return
viewModelScope.launch {
_state.update { it.copy(isCreatingJob = true, error = null, job = null, outputFilePath = null) }
val result = createTtsJobUseCase(
bookId = bookRemoteId,
engine = engine.id,
voiceId = voice.id,
speed = _state.value.speed,
)
val job = result.getOrElse {
_state.update { it.copy(isCreatingJob = false, error = it.error ?: "Failed to create TTS job") }
return@launch
}
_state.update { it.copy(isCreatingJob = false, job = job) }
enqueueDownloadWorker(job, bookRemoteId)
}
}
fun refreshJob(jobId: String) {
viewModelScope.launch {
val result = getTtsJobUseCase(jobId)
_state.update {
it.copy(
job = result.getOrNull() ?: it.job,
error = result.exceptionOrNull()?.message ?: it.error,
)
}
}
}
private fun enqueueDownloadWorker(job: TtsJob, bookRemoteId: String) {
val request = OneTimeWorkRequestBuilder<TtsDownloadWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setInputData(
TtsDownloadWorker.createInputData(
jobId = job.jobId,
bookId = bookRemoteId,
)
)
.build()
workManager.enqueueUniqueWork(
"tts_download_${job.jobId}",
ExistingWorkPolicy.KEEP,
request,
)
observeWorker(request.id)
}
private fun observeWorker(workId: java.util.UUID) {
workManager.getWorkInfoByIdLiveData(workId)
.observeForever { info: WorkInfo? ->
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 message = info.outputData.getString(TtsDownloadWorker.KEY_ERROR_MESSAGE)
_state.update { it.copy(error = message ?: "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 -> {}
}
}
}
}

View file

@ -0,0 +1,30 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.presentation.tts
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.parcelize.Parcelize
import org.dueattendant149.bookshelf.presentation.navigator.Screen
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
import org.dueattendant149.bookshelf.ui.tts.TtsContent
@Parcelize
data class TtsScreen(val bookRemoteId: String) : Screen, Parcelable {
@Composable
override fun Content() {
val model = hiltViewModel<TtsModel>()
val navigator = LocalNavigator.current
TtsContent(
model = model,
bookRemoteId = bookRemoteId,
navigateBack = navigator::pop,
)
}
}

View file

@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.CloudQueue
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@ -45,7 +46,10 @@ import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlace
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RemoteLibraryContent(model: RemoteLibraryModel) {
fun RemoteLibraryContent(
model: RemoteLibraryModel,
navigateToTts: (bookRemoteId: String) -> Unit,
) {
val state by model.state.collectAsStateWithLifecycle()
val refreshState = rememberPullRefreshState(
refreshing = state.isLoading,
@ -144,6 +148,14 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
text = book.author.getAsString()
?: stringResource(R.string.unknown_author)
)
},
trailingContent = {
Button(
onClick = { navigateToTts(book.remoteId) },
enabled = book.remoteId.isNotBlank(),
) {
Text(stringResource(R.string.tts_button))
}
}
)
}

View file

@ -0,0 +1,239 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookshelf.ui.tts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
import org.dueattendant149.bookshelf.presentation.tts.TtsModel
import org.dueattendant149.bookshelf.ui.navigator.NavigatorBackIconButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TtsContent(
model: TtsModel,
bookRemoteId: String,
navigateBack: () -> Unit,
) {
val state by model.state.collectAsStateWithLifecycle()
LaunchedEffect(bookRemoteId) {
model.load(bookRemoteId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.tts_screen)) },
navigationIcon = {
NavigatorBackIconButton(navigateBack = navigateBack)
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
text = stringResource(R.string.tts_book_id),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = bookRemoteId,
style = MaterialTheme.typography.bodySmall,
)
}
if (state.isLoading && state.engines.isEmpty()) {
item {
CircularProgressIndicator()
}
}
state.error?.let { error ->
item {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
)
}
}
item {
Text(
text = stringResource(R.string.tts_engine),
style = MaterialTheme.typography.titleMedium,
)
}
items(state.engines.size) { index ->
val engine = state.engines[index]
EngineItem(
engine = engine,
selected = state.selectedEngine?.id == engine.id,
onClick = { model.selectEngine(engine) }
)
}
item {
Text(
text = stringResource(R.string.tts_voice),
style = MaterialTheme.typography.titleMedium,
)
}
items(state.voices.size) { index ->
val voice = state.voices[index]
VoiceItem(
voice = voice,
selected = state.selectedVoice?.id == voice.id,
onClick = { model.selectVoice(voice) }
)
}
item {
Text(
text = stringResource(R.string.tts_speed),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.tts_speed_label, state.speed),
style = MaterialTheme.typography.bodyMedium,
)
Slider(
value = state.speed.toFloat(),
onValueChange = { model.updateSpeed(it.toDouble()) },
valueRange = 0.5f..2.0f,
steps = 30,
modifier = Modifier.fillMaxWidth()
)
}
item {
Button(
onClick = { model.createJob(bookRemoteId) },
enabled = state.selectedEngine != null &&
state.selectedVoice != null &&
!state.isCreatingJob &&
state.job?.isRunning != true,
modifier = Modifier.fillMaxWidth()
) {
if (state.isCreatingJob) {
CircularProgressIndicator()
} else {
Text(stringResource(R.string.tts_create_job))
}
}
}
state.job?.let { job ->
item {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.tts_status_label, job.status),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(16.dp)
)
LinearProgressIndicator(
progress = { job.progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Text(
text = stringResource(
R.string.tts_progress_label,
job.completedChapters,
job.totalChapters
),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(16.dp)
)
}
}
}
state.outputFilePath?.let { path ->
item {
Text(
text = stringResource(R.string.tts_downloaded_label, path),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
@Composable
private fun EngineItem(
engine: TtsEngine,
selected: Boolean,
onClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(engine.name) },
supportingContent = { Text(engine.id) },
trailingContent = {
if (selected) {
Text(stringResource(R.string.tts_selected_label))
}
},
modifier = Modifier.fillMaxWidth(),
tonalElevation = if (selected) 4.dp else 0.dp,
shadowElevation = if (selected) 2.dp else 0.dp,
)
}
@Composable
private fun VoiceItem(
voice: TtsVoice,
selected: Boolean,
onClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(voice.name) },
supportingContent = { Text("${voice.language}${voice.engine}") },
trailingContent = {
if (selected) {
Text(stringResource(R.string.tts_selected_label))
}
},
modifier = Modifier.fillMaxWidth(),
tonalElevation = if (selected) 4.dp else 0.dp,
shadowElevation = if (selected) 2.dp else 0.dp,
)
}

View file

@ -553,4 +553,13 @@
<string name="remove_content_desc">Remove</string>
<string name="note_content_desc">Note</string>
<!-- TTS (additional labels) -->
<string name="tts_status_label">Status: %1$s</string>
<string name="tts_progress_label">Progress: %1$d / %2$d chapters</string>
<string name="tts_downloaded_label">Saved to: %1$s</string>
<string name="tts_selected_label">Selected</string>
<string name="tts_speed_label">Speed: %1$.2fx</string>
<string name="tts_create_job_button">Create TTS job</string>
<string name="tts_book_id_label">Book: %1$s</string>
</resources>