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:
parent
ed3afc1f16
commit
a6f8b6782a
17 changed files with 990 additions and 4 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue