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
|
|
@ -179,4 +179,5 @@ dependencies {
|
||||||
|
|
||||||
// Background work (cache, TTS sync)
|
// Background work (cache, TTS sync)
|
||||||
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
||||||
|
implementation("androidx.hilt:hilt-work:1.2.0")
|
||||||
}
|
}
|
||||||
|
|
@ -7,13 +7,25 @@
|
||||||
package org.dueattendant149.bookshelf
|
package org.dueattendant149.bookshelf
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
|
import androidx.work.Configuration
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import org.dueattendant149.bookshelf.core.crash.CrashHandler
|
import org.dueattendant149.bookshelf.core.crash.CrashHandler
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class Application : Application() {
|
class Application : Application(), Configuration.Provider {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
|
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.TextParser
|
||||||
import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl
|
import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.BookRepositoryImpl
|
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.CategoryRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
|
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.BookRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
|
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
|
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.HistoryRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.PermissionRepository
|
import org.dueattendant149.bookshelf.domain.repository.PermissionRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
||||||
|
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|
@ -96,6 +97,12 @@ abstract class RepositoryModule {
|
||||||
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
|
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
|
||||||
): RemoteLibraryRepository
|
): RemoteLibraryRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindRemoteTtsRepository(
|
||||||
|
remoteTtsRepositoryImpl: RemoteTtsRepositoryImpl
|
||||||
|
): RemoteTtsRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindBookMapper(
|
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 androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
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.bookshelf.RemoteLibraryContent
|
||||||
|
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object RemoteLibraryScreen : Screen, Parcelable {
|
object RemoteLibraryScreen : Screen, Parcelable {
|
||||||
|
|
@ -19,6 +21,12 @@ object RemoteLibraryScreen : Screen, Parcelable {
|
||||||
@Composable
|
@Composable
|
||||||
override fun Content() {
|
override fun Content() {
|
||||||
val model = hiltViewModel<RemoteLibraryModel>()
|
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.PullRefreshIndicator
|
||||||
import androidx.compose.material.pullrefresh.pullRefresh
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -45,7 +46,10 @@ import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlace
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
fun RemoteLibraryContent(
|
||||||
|
model: RemoteLibraryModel,
|
||||||
|
navigateToTts: (bookRemoteId: String) -> Unit,
|
||||||
|
) {
|
||||||
val state by model.state.collectAsStateWithLifecycle()
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
val refreshState = rememberPullRefreshState(
|
val refreshState = rememberPullRefreshState(
|
||||||
refreshing = state.isLoading,
|
refreshing = state.isLoading,
|
||||||
|
|
@ -144,6 +148,14 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
||||||
text = book.author.getAsString()
|
text = book.author.getAsString()
|
||||||
?: stringResource(R.string.unknown_author)
|
?: 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="remove_content_desc">Remove</string>
|
||||||
<string name="note_content_desc">Note</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>
|
</resources>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue