From cacfe2c7c4078e01d3b969a362019d832f504d4c Mon Sep 17 00:00:00 2001 From: Atte149 Date: Wed, 24 Jun 2026 18:01:57 +0300 Subject: [PATCH] feat(phase4): port RSVP engine, audio player, TTS repo, workers from Book's Story - RSVP: RsvpEngine, RsvpTokenizer, RsvpToken, ReaderText model (domain models ported). - Audio: AudioPlaybackService (@AndroidEntryPoint, ExoPlayer, MediaSession), AudioTrack, AudioPlayerProgress, Book stub. - TTS: RemoteTtsRepository interface + RemoteTtsRepositoryImpl (wraps BookshelfApiRepository). - Workers: no-op stubs for CacheDownloadWorker, ProgressSyncWorker, TtsDownloadWorker. - DI: PlaybackModule provides ExoPlayer singleton. - Strings: added audio_playback_channel resources. - ':app:assembleOssDebug' passes. --- .../bookreader/audio/AudioPlaybackService.kt | 118 ++++++++++++ .../bookreader/audio/AudioPlayerProgress.kt | 29 +++ .../bookreader/audio/AudioTrack.kt | 8 + .../dueattendant149/bookreader/audio/Book.kt | 18 ++ .../bookreader/di/PlaybackModule.kt | 20 ++ .../bookreader/rsvp/ReaderText.kt | 9 + .../bookreader/rsvp/RsvpEngine.kt | 173 ++++++++++++++++++ .../bookreader/rsvp/RsvpToken.kt | 54 ++++++ .../bookreader/rsvp/RsvpTokenizer.kt | 136 ++++++++++++++ .../bookreader/tts/RemoteTtsRepository.kt | 14 ++ .../bookreader/tts/RemoteTtsRepositoryImpl.kt | 23 +++ .../bookreader/tts/TtsModels.kt | 50 +++++ .../bookreader/work/Workers.kt | 35 ++++ app/src/main/res/values/strings.xml | 2 + 14 files changed, 689 insertions(+) create mode 100644 app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlaybackService.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlayerProgress.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/audio/AudioTrack.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/audio/Book.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/di/PlaybackModule.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/rsvp/ReaderText.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpEngine.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpToken.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpTokenizer.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepository.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepositoryImpl.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/tts/TtsModels.kt create mode 100644 app/src/main/java/org/dueattendant149/bookreader/work/Workers.kt diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlaybackService.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlaybackService.kt new file mode 100644 index 0000000..c3b4432 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlaybackService.kt @@ -0,0 +1,118 @@ +/* + * 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.bookreader.audio + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.core.app.NotificationCompat +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import dagger.hilt.android.AndroidEntryPoint +import org.dueattendant149.bookreader.R +import org.dueattendant149.bookreader.audio.Book +import org.dueattendant149.bookreader.MainActivity +import javax.inject.Inject + +@AndroidEntryPoint +@OptIn(UnstableApi::class) +class AudioPlaybackService : MediaSessionService() { + + @Inject + lateinit var exoPlayer: ExoPlayer + + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags) + mediaSession = MediaSession.Builder(this, exoPlayer) + .setSessionActivity(sessionActivity) + .build() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java) + } else { + @Suppress("DEPRECATION") + intent?.getParcelableExtra(EXTRA_BOOK) + } + + val notification = buildNotification(book) + startForeground(NOTIFICATION_ID, notification) + + return super.onStartCommand(intent, flags, startId) + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + return mediaSession + } + + override fun onTaskRemoved(rootIntent: Intent?) { + val player = mediaSession?.player ?: exoPlayer + if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) { + stopSelf() + } + } + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + } + mediaSession = null + super.onDestroy() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.audio_playback_channel), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.audio_playback_channel_description) + } + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + } + + private fun buildNotification(book: Book?): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(book?.title ?: getString(R.string.app_name)) + .setContentText(book?.author ?: "") + .setSmallIcon(R.mipmap.ic_launcher) + .setOngoing(true) + .setSilent(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .build() + } + + companion object { + const val EXTRA_BOOK = "extra_book" + private const val CHANNEL_ID = "audio_playback" + private const val NOTIFICATION_ID = 1 + } +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlayerProgress.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlayerProgress.kt new file mode 100644 index 0000000..bcf6258 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioPlayerProgress.kt @@ -0,0 +1,29 @@ +/* + * 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.bookreader.audio + +/** + * Placeholder for the audio player progress state. + * + * TODO: When implementing the ExoPlayer/MediaSession audio player, + * hook [org.dueattendant149.bookreader.domain.use_case.remote.SyncPlaybackProgressUseCase] + * into the playback position update flow, e.g.: + * + * ``` + * val book = ... + * val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile + * val position = player.currentPosition.coerceAtLeast(0L) + * val duration = player.duration.coerceAtLeast(book.audioDuration) + * syncPlaybackProgressUseCase(book, currentFile, position, duration) + * ``` + */ +data class AudioPlayerProgress( + val bookId: Int, + val currentFile: String, + val position: Long, + val duration: Long, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/AudioTrack.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioTrack.kt new file mode 100644 index 0000000..62de39f --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/audio/AudioTrack.kt @@ -0,0 +1,8 @@ +package org.dueattendant149.bookreader.audio + +data class AudioTrack( + val fileId: String, + val title: String, + val durationMs: Long, + val order: Int, +) diff --git a/app/src/main/java/org/dueattendant149/bookreader/audio/Book.kt b/app/src/main/java/org/dueattendant149/bookreader/audio/Book.kt new file mode 100644 index 0000000..381f7be --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/audio/Book.kt @@ -0,0 +1,18 @@ +package org.dueattendant149.bookreader.audio + +/** + * Minimal Book model stub for AudioPlaybackService. + * Ported from Book's Story domain model. + */ +data class Book( + val id: Long = 0, + val title: String = "", + val author: String = "", + val remoteId: String = "", + val libraryId: String = "", + val hasAudio: Boolean = false, + val audioDuration: Long = 0L, + val audioCurrentFile: String = "", + val audioCurrentPosition: Long = 0L, + val coverUrl: String = "", +) \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookreader/di/PlaybackModule.kt b/app/src/main/java/org/dueattendant149/bookreader/di/PlaybackModule.kt new file mode 100644 index 0000000..5434d28 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/di/PlaybackModule.kt @@ -0,0 +1,20 @@ +package org.dueattendant149.bookreader.di + +import android.content.Context +import androidx.media3.exoplayer.ExoPlayer +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PlaybackModule { + + @Provides + @Singleton + fun provideExoPlayer(@ApplicationContext context: Context): ExoPlayer = + ExoPlayer.Builder(context).build() +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/ReaderText.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/ReaderText.kt new file mode 100644 index 0000000..c8399df --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/rsvp/ReaderText.kt @@ -0,0 +1,9 @@ +package org.dueattendant149.bookreader.rsvp + +sealed class ReaderText { + data class Paragraph(val text: String) : ReaderText() + data class Heading(val text: String) : ReaderText() + data class Chapter(val title: String) : ReaderText() + data class Separator(val text: String = "") : ReaderText() + data class Image(val alt: String = "") : ReaderText() +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpEngine.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpEngine.kt new file mode 100644 index 0000000..8bf2271 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpEngine.kt @@ -0,0 +1,173 @@ +/* + * 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.bookreader.rsvp + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Drives RSVP playback for a fixed token stream. + * + * @param scope scope used for the playback loop (typically `viewModelScope`). + */ +class RsvpEngine(private val scope: CoroutineScope) { + + data class State( + val currentIndex: Int = 0, + val isPlaying: Boolean = false, + val tokens: List = emptyList(), + val wpm: Int = 350, + val pauseOnParagraphEnd: Boolean = true, + val pauseOnChapterEnd: Boolean = true, + val pauseOnLongWords: Boolean = true, + ) { + val progress: Float + get() = if (tokens.isEmpty()) 0f + else currentIndex.toFloat() / tokens.lastIndex.coerceAtLeast(1) + + val currentToken: RsvpToken? + get() = tokens.getOrNull(currentIndex) + + val isAtEnd: Boolean + get() = tokens.isNotEmpty() && currentIndex >= tokens.lastIndex + } + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + private var playbackJob: Job? = null + + fun setTokens(tokens: List) { + stop() + _state.value = _state.value.copy( + tokens = tokens, + currentIndex = 0, + isPlaying = false, + ) + } + + fun setWpm(wpm: Int) { + _state.value = _state.value.copy(wpm = wpm.coerceIn(50, 1500)) + } + + fun setPauseOnParagraphEnd(value: Boolean) { + _state.value = _state.value.copy(pauseOnParagraphEnd = value) + } + + fun setPauseOnChapterEnd(value: Boolean) { + _state.value = _state.value.copy(pauseOnChapterEnd = value) + } + + fun setPauseOnLongWords(value: Boolean) { + _state.value = _state.value.copy(pauseOnLongWords = value) + } + + fun seekToIndex(index: Int) { + val s = _state.value + if (s.tokens.isEmpty()) return + val clamped = index.coerceIn(0, s.tokens.lastIndex) + _state.value = s.copy(currentIndex = clamped) + } + + fun seekToProgress(progress: Float) { + val s = _state.value + if (s.tokens.isEmpty()) return + val clamped = progress.coerceIn(0f, 1f) + val idx = (clamped * s.tokens.lastIndex).toInt() + _state.value = s.copy(currentIndex = idx) + } + + fun skipForward(count: Int = 10) { + seekToIndex(_state.value.currentIndex + count) + } + + fun skipBackward(count: Int = 10) { + seekToIndex(_state.value.currentIndex - count) + } + + fun play() { + val s = _state.value + if (s.tokens.isEmpty()) return + if (s.isAtEnd) { + seekToIndex(0) + } + if (_state.value.isPlaying) return + _state.value = _state.value.copy(isPlaying = true) + startPlaybackLoop() + } + + fun pause() { + _state.value = _state.value.copy(isPlaying = false) + playbackJob?.cancel() + playbackJob = null + } + + fun toggle() { + if (_state.value.isPlaying) pause() else play() + } + + fun stop() { + pause() + } + + private fun startPlaybackLoop() { + playbackJob?.cancel() + playbackJob = scope.launch { + while (isActive && _state.value.isPlaying) { + val snapshot = _state.value + if (snapshot.isAtEnd) { + _state.value = snapshot.copy(isPlaying = false) + return@launch + } + + val token = snapshot.tokens[snapshot.currentIndex] + delay(delayMsFor(token, snapshot)) + + val after = _state.value + if (!after.isPlaying) return@launch + if (after.tokens.isEmpty()) return@launch + + _state.value = after.copy( + currentIndex = (after.currentIndex + 1).coerceAtMost(after.tokens.lastIndex) + ) + } + } + } + + /** + * Compute the delay (in ms) before advancing past [token]. + * + * Base = 60_000 / wpm, adjusted by: + * - per-word multiplier (long words / digits) + * - paragraph-end bonus (configurable, default +200 ms) + * - chapter-end bonus (configurable, default +400 ms) + */ + private fun delayMsFor(token: RsvpToken, snapshot: State): Long { + val wpm = snapshot.wpm.coerceAtLeast(50) + val baseMs = 60_000.0 / wpm + var ms = baseMs * token.multiplier + + if (token.isParagraphEnd && snapshot.pauseOnParagraphEnd) { + ms += 200.0 + } + if (token.isChapterEnd && snapshot.pauseOnChapterEnd) { + ms += 400.0 + } + if (token.multiplier > 1.2f && snapshot.pauseOnLongWords) { + // multiplier already applied above; keep it + } else if (token.multiplier > 1.2f && !snapshot.pauseOnLongWords) { + ms = baseMs + } + return ms.toLong().coerceAtLeast(20L) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpToken.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpToken.kt new file mode 100644 index 0000000..69edbeb --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpToken.kt @@ -0,0 +1,54 @@ +/* + * 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.bookreader.rsvp + +import androidx.compose.runtime.Immutable + +/** + * One word/segment in the RSVP stream. + * + * @param word the original word as it appears in the book. + * @param pivotIndex index of the optimal recognition point within [word]; + * characters before are [prefix], the pivot is [pivot] char, after are [suffix]. + * @param isParagraphEnd true if the token is the last word of a paragraph (longer pause). + * @param isChapterEnd true if the token follows a chapter heading. + * @param multiplier duration multiplier (1.0 baseline) for per-word pacing. + */ +@Immutable +data class RsvpToken( + val word: String, + val pivotIndex: Int, + val isParagraphEnd: Boolean, + val isChapterEnd: Boolean, + val multiplier: Float = 1f, +) { + val prefix: String + get() = word.substring(0, pivotIndex) + + val pivot: String + get() = word.substring(pivotIndex, pivotIndex + 1) + + val suffix: String + get() = word.substring(pivotIndex + 1) +} + +/** + * Optimal recognition point (ORP) lookup for word lengths 1..13+. + * Indices are 0-based positions of the focal character. + * Values follow the heuristic table from Spritz/speed-reading literature: + * 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3. + */ +object OrpTable { + private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3) + + fun pivotFor(word: String): Int { + val len = word.length + if (len == 0) return 0 + if (len <= TABLE.size) return TABLE[len - 1] + return (len * 0.3f).toInt().coerceIn(1, len - 1) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpTokenizer.kt b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpTokenizer.kt new file mode 100644 index 0000000..b23d76b --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/rsvp/RsvpTokenizer.kt @@ -0,0 +1,136 @@ +/* + * 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.bookreader.rsvp + +import org.dueattendant149.bookreader.rsvp.ReaderText + +/** + * Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader) + * into a flat list of [RsvpToken]s suitable for RSVP playback. + * + * - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image]. + * - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`. + * - Marks the first word after a chapter heading as `isChapterEnd = true`. + * - Strips punctuation-only tokens and empty whitespace tokens. + */ +object RsvpTokenizer { + + /** + * Tokenize a list of [ReaderText] blocks (as produced by the reader parser). + */ + fun tokenizeReaderText(blocks: List): List { + val tokens = ArrayList(blocks.size * 8) + var lastWasParagraphEnd = false + var pendingChapterEnd = false + + for (block in blocks) { + when (block) { + is ReaderText.Chapter -> { + pendingChapterEnd = true + lastWasParagraphEnd = false + } + + is ReaderText.Paragraph -> { + val raw = block.text + if (raw.isBlank()) { + lastWasParagraphEnd = true + } else { + tokenizeLine( + line = raw, + isParagraphEnd = lastWasParagraphEnd, + isChapterEnd = pendingChapterEnd, + out = tokens, + ) + lastWasParagraphEnd = false + pendingChapterEnd = false + } + } + + is ReaderText.Heading -> { + val raw = block.text + if (raw.isNotBlank()) { + tokenizeLine( + line = raw, + isParagraphEnd = lastWasParagraphEnd, + isChapterEnd = pendingChapterEnd, + out = tokens, + ) + lastWasParagraphEnd = false + pendingChapterEnd = false + } + } + + is ReaderText.Separator -> lastWasParagraphEnd = true + is ReaderText.Image -> Unit + } + } + + return tokens + } + + private fun tokenizeLine( + line: String, + isParagraphEnd: Boolean, + isChapterEnd: Boolean, + out: MutableList, + ) { + val words = line.split(WORD_SPLIT_REGEX).filter { it.isNotBlank() } + if (words.isEmpty()) return + + val lastIndex = words.lastIndex + for ((idx, word) in words.withIndex()) { + val cleaned = cleanWord(word) + if (cleaned.isEmpty()) continue + val pivot = OrpTable.pivotFor(cleaned) + val paragraphEnd = isParagraphEnd && idx == lastIndex + val multiplier = computeMultiplier(cleaned) + out.add( + RsvpToken( + word = cleaned, + pivotIndex = pivot, + isParagraphEnd = paragraphEnd, + isChapterEnd = isChapterEnd && idx == 0, + multiplier = multiplier, + ) + ) + } + } + + private val WORD_SPLIT_REGEX = Regex("\\s+") + + private fun cleanWord(raw: String): String { + val sb = StringBuilder(raw.length) + var i = 0 + val len = raw.length + while (i < len) { + val c = raw[i] + if (c.isLetterOrDigit() || c == '-' || c == '\'') { + sb.append(c) + } + i++ + } + return sb.toString() + } + + /** + * Per-word pacing multiplier. + * + * - Long words (>9 chars) get +30% time for recognition. + * - Numbers/digits get +20% time. + * - Baseline 1.0 for normal words. + */ + private fun computeMultiplier(word: String): Float { + val len = word.length + val base = when { + len > 9 -> 1.3f + len > 6 -> 1.1f + else -> 1.0f + } + val hasDigit = word.any { it.isDigit() } + return if (hasDigit) base + 0.2f else base + } +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepository.kt b/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepository.kt new file mode 100644 index 0000000..0f5c2e6 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepository.kt @@ -0,0 +1,14 @@ +package org.dueattendant149.bookreader.tts + +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse +import okhttp3.ResponseBody + +interface RemoteTtsRepository { + suspend fun fetchEngines(): Result + suspend fun fetchVoices(engine: String? = null): Result + suspend fun createJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result + suspend fun getJob(jobId: String): Result + suspend fun downloadAudio(jobId: String): Result +} diff --git a/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepositoryImpl.kt new file mode 100644 index 0000000..ce836e0 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/tts/RemoteTtsRepositoryImpl.kt @@ -0,0 +1,23 @@ +package org.dueattendant149.bookreader.tts + +import okhttp3.ResponseBody +import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse +import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RemoteTtsRepositoryImpl + @Inject + constructor( + private val bookshelfApi: BookshelfApiRepository, + ) : RemoteTtsRepository { + override suspend fun fetchEngines(): Result = bookshelfApi.getTtsEngines() + override suspend fun fetchVoices(engine: String?): Result = bookshelfApi.getTtsVoices(engine) + override suspend fun createJob(request: TtsCreateRequest): Result = bookshelfApi.createTtsJob(request) + override suspend fun getJob(jobId: String): Result = bookshelfApi.getTtsJobStatus(jobId) + override suspend fun downloadAudio(jobId: String): Result = bookshelfApi.downloadTtsAudio(jobId) + } diff --git a/app/src/main/java/org/dueattendant149/bookreader/tts/TtsModels.kt b/app/src/main/java/org/dueattendant149/bookreader/tts/TtsModels.kt new file mode 100644 index 0000000..a632e0f --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/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.bookreader.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/bookreader/work/Workers.kt b/app/src/main/java/org/dueattendant149/bookreader/work/Workers.kt new file mode 100644 index 0000000..b487f14 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookreader/work/Workers.kt @@ -0,0 +1,35 @@ +package org.dueattendant149.bookreader.work + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters + +/** + * Stub for CacheDownloadWorker. No-op until full Book's Story data layer is ported. + */ +class CacheDownloadWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result = Result.success() +} + +/** + * Stub for ProgressSyncWorker. No-op until full Book's Story data layer is ported. + */ +class ProgressSyncWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result = Result.success() +} + +/** + * Stub for TtsDownloadWorker. No-op until full Book's Story data layer is ported. + */ +class TtsDownloadWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result = Result.success() +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f8c6274..4db678c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2066,4 +2066,6 @@ Reader text, highlights, and locations stay unchanged. %1$s -> %2$s + Audio Playback + Audio playback controls