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.
This commit is contained in:
Atte149 2026-06-24 18:01:57 +03:00
parent f7bffd0b06
commit cacfe2c7c4
14 changed files with 689 additions and 0 deletions

View file

@ -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
}
}

View file

@ -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,
)

View file

@ -0,0 +1,8 @@
package org.dueattendant149.bookreader.audio
data class AudioTrack(
val fileId: String,
val title: String,
val durationMs: Long,
val order: Int,
)

View file

@ -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 = "",
)

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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<RsvpToken> = 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> = _state.asStateFlow()
private var playbackJob: Job? = null
fun setTokens(tokens: List<RsvpToken>) {
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)
}
}

View file

@ -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:
* 10, 20, 31, 41, 51, 62, 72, 82, 92, 103, 113, 123, 133.
*/
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)
}
}

View file

@ -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<ReaderText>): List<RsvpToken> {
val tokens = ArrayList<RsvpToken>(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<RsvpToken>,
) {
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
}
}

View file

@ -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<TtsEnginesResponse>
suspend fun fetchVoices(engine: String? = null): Result<TtsVoicesResponse>
suspend fun createJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result<TtsJobResponse>
suspend fun getJob(jobId: String): Result<TtsJobResponse>
suspend fun downloadAudio(jobId: String): Result<ResponseBody>
}

View file

@ -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<TtsEnginesResponse> = bookshelfApi.getTtsEngines()
override suspend fun fetchVoices(engine: String?): Result<TtsVoicesResponse> = bookshelfApi.getTtsVoices(engine)
override suspend fun createJob(request: TtsCreateRequest): Result<TtsJobResponse> = bookshelfApi.createTtsJob(request)
override suspend fun getJob(jobId: String): Result<TtsJobResponse> = bookshelfApi.getTtsJobStatus(jobId)
override suspend fun downloadAudio(jobId: String): Result<ResponseBody> = bookshelfApi.downloadTtsAudio(jobId)
}

View file

@ -0,0 +1,50 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.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"
}

View file

@ -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()
}

View file

@ -2066,4 +2066,6 @@
<string name="tts_replacements_replace_only_spoken_desc">Reader text, highlights, and locations stay unchanged.</string> <string name="tts_replacements_replace_only_spoken_desc">Reader text, highlights, and locations stay unchanged.</string>
<!-- TTS replacement summary. %1$s = replacement source; %2$s = spoken replacement. Example: "Dr. -> Doctor". --> <!-- TTS replacement summary. %1$s = replacement source; %2$s = spoken replacement. Example: "Dr. -> Doctor". -->
<string name="tts_replacements_summary_format">%1$s -&gt; %2$s</string> <string name="tts_replacements_summary_format">%1$s -&gt; %2$s</string>
<string name="audio_playback_channel">Audio Playback</string>
<string name="audio_playback_channel_description">Audio playback controls</string>
</resources> </resources>