diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5d543541..1719492c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -179,4 +179,5 @@ dependencies { // Background work (cache, TTS sync) implementation("androidx.work:work-runtime-ktx:2.10.0") + implementation("androidx.hilt:hilt-work:1.2.0") } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/Application.kt b/app/src/main/java/org/dueattendant149/bookshelf/Application.kt index a98d369d..08b839e1 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/Application.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/Application.kt @@ -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() } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt index 3d2018df..5c77ac01 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt @@ -37,6 +37,7 @@ 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.AudiobookshelfRepository import org.dueattendant149.bookshelf.domain.repository.BookRepository import org.dueattendant149.bookshelf.domain.repository.CacheRepository @@ -46,6 +47,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 @@ -111,6 +113,12 @@ abstract class RepositoryModule { audiobookshelfRepositoryImpl: AudiobookshelfRepositoryImpl ): AudiobookshelfRepository + @Binds + @Singleton + abstract fun bindRemoteTtsRepository( + remoteTtsRepositoryImpl: RemoteTtsRepositoryImpl + ): RemoteTtsRepository + @Binds @Singleton abstract fun bindBookMapper( diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteTtsRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteTtsRepositoryImpl.kt new file mode 100644 index 00000000..36a6a16a --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteTtsRepositoryImpl.kt @@ -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> { + 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> { + 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 { + 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 { + 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 { + 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, + ) + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt new file mode 100644 index 00000000..f8de212a --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt @@ -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() + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/tts/TtsModels.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/tts/TtsModels.kt new file mode 100644 index 00000000..eb3c447d --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/tts/TtsModels.kt @@ -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" +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteTtsRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteTtsRepository.kt new file mode 100644 index 00000000..ba2ca7c1 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteTtsRepository.kt @@ -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> + suspend fun fetchVoices(engine: String? = null): Result> + suspend fun createJob( + bookId: String, + engine: String, + voiceId: String, + speed: Double, + ): Result + + suspend fun getJob(jobId: String): Result + suspend fun downloadAudio(jobId: String): Result +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/CreateTtsJobUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/CreateTtsJobUseCase.kt new file mode 100644 index 00000000..3eb81d46 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/CreateTtsJobUseCase.kt @@ -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 = repository.createJob(bookId, engine, voiceId, speed) + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsEnginesUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsEnginesUseCase.kt new file mode 100644 index 00000000..5b18f029 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsEnginesUseCase.kt @@ -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> = repository.fetchEngines() + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsVoicesUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsVoicesUseCase.kt new file mode 100644 index 00000000..db319bb9 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsVoicesUseCase.kt @@ -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> = + repository.fetchVoices(engine) + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/GetTtsJobUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/GetTtsJobUseCase.kt new file mode 100644 index 00000000..3c80ed66 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/GetTtsJobUseCase.kt @@ -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 = repository.getJob(jobId) + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt index e3e6a22d..6a185c26 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt @@ -16,6 +16,7 @@ import org.dueattendant149.bookshelf.presentation.history.HistoryScreen import org.dueattendant149.bookshelf.presentation.navigator.Screen import org.dueattendant149.bookshelf.presentation.player.PlayerScreen import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen +import org.dueattendant149.bookshelf.presentation.tts.TtsScreen import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator @@ -42,6 +43,9 @@ object RemoteLibraryScreen : Screen, Parcelable { model = model, onPlayAudio = { book -> navigator.push(PlayerScreen(book)) + }, + navigateToTts = { bookRemoteId -> + navigator.push(TtsScreen(bookRemoteId)) } ) } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsModel.kt new file mode 100644 index 00000000..71870069 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsModel.kt @@ -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 = emptyList(), + val voices: List = 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() + .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 -> {} + } + } + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsScreen.kt new file mode 100644 index 00000000..680ee0a5 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsScreen.kt @@ -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() + val navigator = LocalNavigator.current + TtsContent( + model = model, + bookRemoteId = bookRemoteId, + navigateBack = navigator::pop, + ) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt index cf35411f..bbb844c8 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt @@ -62,6 +62,7 @@ import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlace fun RemoteLibraryContent( model: RemoteLibraryModel, onPlayAudio: (Book) -> Unit, + navigateToTts: (bookRemoteId: String) -> Unit, ) { val state by model.state.collectAsStateWithLifecycle() val refreshState = rememberPullRefreshState( @@ -182,7 +183,8 @@ fun RemoteLibraryContent( onDownload = { model.cacheBook(book) }, onDelete = { model.deleteCache(book) }, onRead = if (book.hasEbook) ({ model.openBook(book) }) else null, - onPlayAudio = if (book.hasAudio) ({ onPlayAudio(book) }) else null + onPlayAudio = if (book.hasAudio) ({ onPlayAudio(book) }) else null, + onTts = { navigateToTts(book.remoteId) } ) } @@ -240,6 +242,7 @@ private fun BookListItem( onDelete: () -> Unit, onRead: (() -> Unit)?, onPlayAudio: (() -> Unit)?, + onTts: () -> Unit, ) { val cacheState = cacheStatus?.state ?: CacheState.NONE val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED @@ -318,6 +321,9 @@ private fun BookListItem( .padding(bottom = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) ) { + Button(onClick = onTts) { + Text(stringResource(R.string.tts_button)) + } onRead?.let { read -> Button( onClick = read, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/tts/TtsContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/tts/TtsContent.kt new file mode 100644 index 00000000..954a8bc4 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/tts/TtsContent.kt @@ -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, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a8865bd..36f37ab8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -559,4 +559,13 @@ Remove Note + + Status: %1$s + Progress: %1$d / %2$d chapters + Saved to: %1$s + Selected + Speed: %1$.2fx + Create TTS job + Book: %1$s + \ No newline at end of file