book-reader/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt
Aryan 9510293ac3
Update v1.0.49 (#330)
* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE

* Refactored desktop reader screens and state management into specialized components

* Added image gallery to reader sidebar and refactored desktop PDF UI components

* Implemented EPUB image gallery

* Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic

* Standardized UI styling and refactored app navigation layout

* Implemented auto-hiding reader chrome and activity tracking in desktop

* Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support.

* Added PPTX support for desktop and refactored parsing into a shared module

* Implement paid AI features and account management for the desktop application.

* Implement AI Hub and enhanced Cloud TTS integration for Desktop

* Implement streaming support for AI definition and summarization features

* Implement support for password-protected PDFs and file actions in the desktop reader.

* Implement cloud synchronization for desktop using Firestore and Google Drive

* Implement PDF reflow and "Text View" for the desktop reader

* Refactor OPDS logic to use SharedOpdsController

* Optimize PDF tile rendering performance

* Implement two-page spread support for PDF pagination

* Implement two-page spread support for the PDF viewer

* Improved shared spread zoom in PDF viewer

* Improve PDF spread navigation with fling support and configurable page gaps

* Add brightness control to PDF and EPUB readers

* Refactor folder synchronization to use shared logic engine

* Implement safe string formatting and validation for localized resources

* Implement TTS chunk skip navigation

* Implement deep-linking and playback controls for TTS media sessions

* Implement start index for TTS playback

* Improve TTS navigation, prefetching, and notification duration reporting

* Implement TTS mini playback bar for background reading

* Implement multi-window reader support for the desktop application

* Improve desktop modal window management and visibility syncing

* Implement localized string support for Desktop and shared UI

* Implement language selection and persistence for Desktop

* Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml

* Implement localized banner messages and UI strings using resource-backed SharedText

* Implement compact badge styling for small book covers

* Refactor PDF native interaction and improve HTML import memory safety

* fix language persistence

* Refactor reader overflow menus to use section-based logic

* Refactor PDF layout remapping and improve text box interaction

* Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets

* Centralize PDF annotation export mapping and improve metadata handling

* Add support for threaded comments in PDF highlight annotations

* Flatten highlight comments into a single thread for PDF export and allow author editing

* Integrate page slider into reader chrome and persist toggle state

* Handle fragments and queries in EPUB chapter paths

* Implement dynamic, theme-aware coloring for the reader slider

* Implement customizable app-wide font preference

* Implement one-hand zoom gestures in the PDF viewer

* Implement File Information dialog for PDF and EPUB readers

* Bump version to 1.0.49 (53)

* Refactor PDF reader logic into modular components

* Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens

* Add option to use PDF filenames as display names

* Fix preservation of PDF filename display preference in library projection
2026-05-20 22:14:01 +05:30

1978 lines
87 KiB
Kotlin

/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
package com.aryan.reader.tts
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import timber.log.Timber
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.aryan.reader.MainActivity
import com.aryan.reader.R
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
import androidx.core.net.toUri
import com.aryan.reader.paginatedreader.TimedWord
import com.aryan.reader.paginatedreader.TtsChunk
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
val START_TTS_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.START")
val STOP_TTS_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.STOP")
val CHANGE_SPEAKER_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.CHANGE_SPEAKER")
val FLUSH_PREFETCH_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.FLUSH_PREFETCH")
private val STATE_UPDATE_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.STATE_UPDATE")
val CHANGE_TTS_MODE_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.CHANGE_MODE")
val SLICE_CURRENT_AND_RELOAD_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD")
val SET_PLAYBACK_PARAMS_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS")
val SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.SKIP_TO_PREVIOUS_CHUNK")
val SKIP_TO_NEXT_TTS_CHUNK_COMMAND: SessionCommand
get() = ttsSessionCommand("com.aryan.reader.tts.SKIP_TO_NEXT_CHUNK")
const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG"
const val TTS_CHUNK_NAV_DIAG_TAG = "TTS_CHUNK_NAV_DIAG"
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
const val KEY_SPOKEN_TEXT_CHUNKS = "KEY_SPOKEN_TEXT_CHUNKS"
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
const val KEY_START_OFFSETS = "KEY_START_OFFSETS"
const val KEY_SPEAKER_ID = "KEY_SPEAKER_ID"
const val KEY_BOOK_TITLE = "KEY_BOOK_TITLE"
const val KEY_CHAPTER_TITLE = "KEY_CHAPTER_TITLE"
const val KEY_COVER_IMAGE_URI = "KEY_COVER_IMAGE_URI"
const val KEY_TTS_MODE = "KEY_TTS_MODE"
const val KEY_WORD_TIMESTAMPS = "KEY_WORD_TIMESTAMPS"
const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS"
const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN"
const val KEY_CHAPTER_INDEX = "KEY_CHAPTER_INDEX"
const val KEY_TOTAL_CHAPTERS = "KEY_TOTAL_CHAPTERS"
const val KEY_CONTINUE_SESSION = "KEY_CONTINUE_SESSION"
const val KEY_BOOK_ID = "KEY_BOOK_ID"
const val KEY_PAGE_INDEX = "KEY_PAGE_INDEX"
const val KEY_START_CHUNK_INDEX = "KEY_START_CHUNK_INDEX"
private const val PREFETCH_LOOKAHEAD = 3
private const val TTS_SESSION_ACTIVITY_REQUEST_CODE = 4207
private const val TTS_STREAM_WAV_HEADER_BYTES = 44L
private const val TTS_STREAM_PCM_BYTES_PER_MS = 48L
private const val TTS_NOTIFICATION_MIN_DURATION_MS = 1_500L
private const val TTS_NOTIFICATION_TRAILING_BUFFER_MS = 2_000L
private const val TTS_NOTIFICATION_AVERAGE_WORD_MS = 550L
private const val TTS_NOTIFICATION_PUNCTUATION_PAUSE_MS = 120L
private const val NO_DEFERRED_TRANSITION_PREFETCH_GENERATION = -1
private val TTS_NOTIFICATION_WORD_PATTERN = Regex("""\S+""")
private fun ttsSessionCommand(action: String): SessionCommand {
return SessionCommand(action, Bundle.EMPTY)
}
internal fun resolveTtsChunkSkipTarget(
currentChunkIndex: Int,
totalChunks: Int,
direction: Int
): Int? {
if (totalChunks <= 0) return null
if (currentChunkIndex !in 0 until totalChunks) return null
if (direction != -1 && direction != 1) return null
val targetIndex = currentChunkIndex + direction
return targetIndex.takeIf { it in 0 until totalChunks }
}
internal fun resolveTtsStartChunkIndex(
requestedChunkIndex: Int,
totalChunks: Int
): Int {
if (totalChunks <= 0) return 0
return requestedChunkIndex.coerceIn(0, totalChunks - 1)
}
internal fun resolveReusableTtsPlaylistIndex(
playlistIndex: Int?,
direction: Int
): Int? {
if (direction != 1) return null
return playlistIndex?.takeIf { it >= 0 }
}
internal fun shouldAdvanceToTtsPlaylistChunk(
currentChunkIndex: Int,
playlistChunkIndex: Int?
): Boolean {
return playlistChunkIndex == currentChunkIndex + 1
}
internal fun shouldStartTtsTransitionPrefetch(
currentGeneration: Int,
deferredGeneration: Int
): Boolean {
return currentGeneration != deferredGeneration
}
internal fun resolveTtsStreamPcmDurationMs(totalBytes: Long): Long? {
if (totalBytes <= TTS_STREAM_WAV_HEADER_BYTES) return null
return ((totalBytes - TTS_STREAM_WAV_HEADER_BYTES) / TTS_STREAM_PCM_BYTES_PER_MS)
.coerceAtLeast(1L)
}
internal fun estimateTtsNotificationDurationMs(
text: String,
currentPositionMs: Long = 0L
): Long? {
val words = TTS_NOTIFICATION_WORD_PATTERN.findAll(text).count()
if (words == 0) return null
val punctuationPauses = text.count { it == '.' || it == '?' || it == '!' || it == ';' || it == ':' }
val estimatedDurationMs = words * TTS_NOTIFICATION_AVERAGE_WORD_MS +
punctuationPauses * TTS_NOTIFICATION_PUNCTUATION_PAUSE_MS
val playbackPositionMinimumMs = if (currentPositionMs > 0L) {
currentPositionMs + TTS_NOTIFICATION_TRAILING_BUFFER_MS
} else {
TTS_NOTIFICATION_MIN_DURATION_MS
}
val minimumDurationMs = maxOf(TTS_NOTIFICATION_MIN_DURATION_MS, playbackPositionMinimumMs)
return estimatedDurationMs.coerceAtLeast(minimumDurationMs)
}
internal fun resolveWavFileDurationMs(file: File): Long? {
if (!file.exists() || file.length() <= TTS_STREAM_WAV_HEADER_BYTES) return null
return try {
val header = ByteArray(TTS_STREAM_WAV_HEADER_BYTES.toInt())
val bytesRead = file.inputStream().use { it.read(header) }
if (bytesRead < header.size) return null
val riff = String(header, 0, 4, Charsets.US_ASCII)
val wave = String(header, 8, 4, Charsets.US_ASCII)
if (riff != "RIFF" || wave != "WAVE") return null
val byteRate = java.nio.ByteBuffer.wrap(header, 28, 4)
.order(java.nio.ByteOrder.LITTLE_ENDIAN)
.int
if (byteRate <= 0) return null
val headerDataSize = java.nio.ByteBuffer.wrap(header, 40, 4)
.order(java.nio.ByteOrder.LITTLE_ENDIAN)
.int
.toLong()
.takeIf { it > 0L && it < file.length() }
val audioBytes = headerDataSize ?: (file.length() - TTS_STREAM_WAV_HEADER_BYTES)
((audioBytes * 1_000L) / byteRate).coerceAtLeast(1L)
} catch (e: Exception) {
Timber.tag("TTS_CLOUD_DIAG").w(e, "Failed to read WAV duration for ${file.name}")
null
}
}
@UnstableApi
class TtsPlaybackManager(
context: Context,
private val player: Player,
private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData,
private val onResetContext: () -> Unit,
private val onPlaybackSessionPreparing: (bookTitle: String?, chapterTitle: String?) -> Unit = { _, _ -> },
private val onPlaybackSessionStopped: () -> Unit = {}
) : MediaSession.Callback, Player.Listener {
private val appContext = context.applicationContext
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var mediaSession: MediaSession? = null
private val prefetchingJobs = java.util.concurrent.ConcurrentHashMap<Int, Job>()
private var wordTrackingJob: Job? = null
private var preparationJob: Job? = null
private var prefetchLoopJob: Job? = null
private val playbackGeneration = AtomicInteger(0)
private val chunkNavLogSequence = AtomicInteger(0)
private val deferredTransitionPrefetchGeneration = AtomicInteger(
NO_DEFERRED_TRANSITION_PREFETCH_GENERATION
)
private var lastPrefetchIndex = -1
private var currentAuthToken: String? = null
private val loadedChunks: MutableSet<Int> = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
private val chunkStreamIds = java.util.concurrent.ConcurrentHashMap<Int, String>()
enum class TtsMode {
CLOUD, BASE
}
data class TtsState(
val isPlaying: Boolean = false,
val isLoading: Boolean = false,
val currentText: String? = null,
val errorMessage: String? = null,
val bookId: String? = null,
val bookTitle: String? = null,
val chapterTitle: String? = null,
val chapterIndex: Int? = null,
val totalChapters: Int? = null,
val pageIndex: Int? = null,
val currentChunkIndex: Int = -1,
val totalChunks: Int = 0,
val bookProgressPercent: Int? = null,
val speakerId: String = DEFAULT_SPEAKER_ID,
val sourceCfi: String? = null,
val startOffsetInSource: Int = -1,
val playbackState: Int = Player.STATE_IDLE,
val sessionEndedByStop: Boolean = false,
val currentWordSourceCfi: String? = null,
val currentWordStartOffset: Int = -1,
val sessionFinished: Boolean = false,
val playbackSource: String? = null,
val ttsMode: String = TtsMode.CLOUD.name
)
private val initialSpeakerId = loadTtsSpeaker(appContext)
private val initialTtsMode = loadTtsMode(appContext)
private val _ttsState = MutableStateFlow(
TtsState(
speakerId = initialSpeakerId,
ttsMode = initialTtsMode.name
)
)
private var textChunks: List<TtsChunk> = emptyList()
private val audioFiles = java.util.concurrent.ConcurrentHashMap<Int, File>()
private var currentSpeakerId = initialSpeakerId
private var bookId: String? = null
private var bookTitle: String? = null
private var chapterTitle: String? = null
private var coverImageUri: String? = null
private var currentTtsMode = initialTtsMode
private var chapterIndex: Int? = null
private var totalChapters: Int? = null
private var pageIndex: Int? = null
init {
player.addListener(this)
_ttsState.onEach { newState ->
updateSessionControls(newState)
}.launchIn(scope)
}
fun setMediaSession(session: MediaSession) {
this.mediaSession = session
updateSessionControls(_ttsState.value)
}
private fun advancePlaybackGeneration(): Int {
return playbackGeneration.incrementAndGet()
}
private fun currentPlaybackGeneration(): Int {
return playbackGeneration.get()
}
private fun isPlaybackGenerationActive(generation: Int): Boolean {
return playbackGeneration.get() == generation
}
private fun deferTransitionPrefetchForGeneration(generation: Int) {
deferredTransitionPrefetchGeneration.set(generation)
logChunkNav(
"transition-prefetch-defer-set",
"generation=$generation"
)
}
private fun releaseTransitionPrefetchForGeneration(generation: Int) {
if (deferredTransitionPrefetchGeneration.compareAndSet(
generation,
NO_DEFERRED_TRANSITION_PREFETCH_GENERATION
)
) {
logChunkNav(
"transition-prefetch-defer-clear",
"generation=$generation"
)
}
}
private fun canStartTransitionPrefetch(): Boolean {
return shouldStartTtsTransitionPrefetch(
currentGeneration = currentPlaybackGeneration(),
deferredGeneration = deferredTransitionPrefetchGeneration.get()
)
}
private fun cancelPrefetchWork() {
logChunkNav("prefetch-cancel", "activePrefetching=${prefetchingJobs.keys.sorted()} lastPrefetch=$lastPrefetchIndex")
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
lastPrefetchIndex = -1
}
private fun logChunkNav(stage: String, details: String) {
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i(
"navEvent=${chunkNavLogSequence.incrementAndGet()} stage=$stage $details ${cacheSnapshot()}"
)
}
private fun logChunkNavMain(stage: String, details: String) {
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i(
"navEvent=${chunkNavLogSequence.incrementAndGet()} stage=$stage $details ${playerSnapshot()} ${stateSnapshot()} ${cacheSnapshot()}"
)
}
private fun logChunkNavWarnMain(stage: String, details: String) {
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).w(
"navEvent=${chunkNavLogSequence.incrementAndGet()} stage=$stage $details ${playerSnapshot()} ${stateSnapshot()} ${cacheSnapshot()}"
)
}
private fun playerSnapshot(): String {
val itemIds = buildList {
for (index in 0 until player.mediaItemCount) {
add("$index:${player.getMediaItemAt(index).mediaId}")
}
}.joinToString(prefix = "[", postfix = "]")
return "playerIndex=${player.currentMediaItemIndex} currentMediaId=${player.currentMediaItem?.mediaId} mediaItems=${player.mediaItemCount} playbackState=${player.playbackState} playWhenReady=${player.playWhenReady} isPlaying=${player.isPlaying} items=$itemIds"
}
private fun stateSnapshot(): String {
val state = _ttsState.value
return "stateChunk=${state.currentChunkIndex}/${state.totalChunks} stateLoading=${state.isLoading} statePlaying=${state.isPlaying} stateFinished=${state.sessionFinished}"
}
private fun cacheSnapshot(): String {
return "generation=${currentPlaybackGeneration()} deferredTransitionPrefetch=${deferredTransitionPrefetchGeneration.get()} lastPrefetch=$lastPrefetchIndex loaded=${loadedChunks.sorted()} audio=${audioFiles.keys.sorted()} streams=${chunkStreamIds.keys.sorted()} prefetching=${prefetchingJobs.keys.sorted()}"
}
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"MediaSession onConnect. package=${controller.packageName}, uid=${controller.uid}"
)
val availableSessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
.add(START_TTS_COMMAND)
.add(STOP_TTS_COMMAND)
.add(CHANGE_SPEAKER_COMMAND)
.add(CHANGE_TTS_MODE_COMMAND)
.add(FLUSH_PREFETCH_COMMAND)
.add(SLICE_CURRENT_AND_RELOAD_COMMAND)
.add(SET_PLAYBACK_PARAMS_COMMAND)
.add(SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND)
.add(SKIP_TO_NEXT_TTS_CHUNK_COMMAND)
.build()
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(availableSessionCommands)
.setAvailablePlayerCommands(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS)
.setCustomLayout(createCustomLayout(_ttsState.value))
.setMediaButtonPreferences(createNotificationButtons())
.setSessionActivity(createSessionActivity(_ttsState.value))
.build()
}
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: List<MediaItem>
): ListenableFuture<List<MediaItem>> {
return Futures.immediateFuture(mediaItems)
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand) {
START_TTS_COMMAND -> {
val chunks = args.getStringArrayList(KEY_TEXT_CHUNKS) ?: emptyList()
Timber.d("TtsService: START command received. Size: ${chunks.size}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"START command received. chunks=${chunks.size}, continueSession=${args.getBoolean(KEY_CONTINUE_SESSION, false)}, source=${args.getString(KEY_PLAYBACK_SOURCE)}, mode=${args.getString(KEY_TTS_MODE)}, chapterIndex=${args.getInt(KEY_CHAPTER_INDEX, -1)}, totalChapters=${args.getInt(KEY_TOTAL_CHAPTERS, -1)}"
)
val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
val offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
val spokenTexts = args.getStringArrayList(KEY_SPOKEN_TEXT_CHUNKS)
val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
val bookTitle = args.getString(KEY_BOOK_TITLE)
val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
val coverImageUri = args.getString(KEY_COVER_IMAGE_URI)
val chapterIndex = args.getInt(KEY_CHAPTER_INDEX, -1).takeIf { it >= 0 }
val totalChapters = args.getInt(KEY_TOTAL_CHAPTERS, -1).takeIf { it > 0 }
val bookId = args.getString(KEY_BOOK_ID)
val pageIndex = args.getInt(KEY_PAGE_INDEX, -1).takeIf { it >= 0 }
val startChunkIndex = args.getInt(KEY_START_CHUNK_INDEX, 0)
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
chunks.mapIndexed { index, text ->
val safeOffset = offsets.getOrNull(index) ?: -1
val spokenText = spokenTexts?.getOrNull(index)?.ifBlank { text } ?: text
TtsChunk(
text = text,
sourceCfi = cfis[index],
startOffsetInSource = safeOffset,
spokenText = spokenText,
)
}
} else {
chunks.mapIndexed { index, text ->
TtsChunk(
text = text,
sourceCfi = "",
startOffsetInSource = -1,
spokenText = spokenTexts?.getOrNull(index)?.ifBlank { text } ?: text,
)
}
}
val authToken = args.getString(KEY_AUTH_TOKEN)
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
handleStartTts(richChunks, speakerId, bookId, bookTitle, chapterTitle, coverImageUri, chapterIndex, totalChapters, pageIndex, startChunkIndex, ttsMode, playbackSource, args)
}
STOP_TTS_COMMAND -> {
Timber.d("Received STOP command.")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("STOP command received.")
handleStopTts(userInitiated = true)
}
CHANGE_SPEAKER_COMMAND -> {
val newSpeakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
handleChangeSpeaker(newSpeakerId)
}
CHANGE_TTS_MODE_COMMAND -> {
val newModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD }
handleChangeTtsMode(newMode)
}
FLUSH_PREFETCH_COMMAND -> {
Timber.d("Flushing prefetched TTS chunks for new parameters.")
advancePlaybackGeneration()
onResetContext()
cancelPrefetchWork()
scope.launch(Dispatchers.Main) {
val currentIdx = currentChunkIndexFromPlayer()
if (currentIdx == C.INDEX_UNSET) return@launch
val keysToRemove = loadedChunks.filter { it > currentIdx }
withContext(Dispatchers.IO) {
keysToRemove.forEach { key ->
loadedChunks.remove(key)
val file = audioFiles.remove(key)
deleteTempFile(file)
val streamId = chunkStreamIds.remove(key)
if (streamId != null) {
StreamRegistry.remove(streamId)
}
}
}
val itemsToRemove = mutableListOf<Int>()
for (k in 0 until player.mediaItemCount) {
val id = player.getMediaItemAt(k).mediaId.toIntOrNull() ?: -1
if (id > currentIdx) {
itemsToRemove.add(k)
}
}
itemsToRemove.reversed().forEach {
player.removeMediaItem(it)
}
prefetchNextChunkAudio(currentIdx)
}
}
SLICE_CURRENT_AND_RELOAD_COMMAND -> {
handleSliceAndReload()
}
SET_PLAYBACK_PARAMS_COMMAND -> {
val speed = args.getFloat("speed", 1f)
val pitch = args.getFloat("pitch", 1f)
if (currentTtsMode == TtsMode.CLOUD) {
scope.launch(Dispatchers.Main) {
player.playbackParameters = androidx.media3.common.PlaybackParameters(speed, pitch)
}
}
}
SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND -> {
handleSkipTtsChunk(direction = -1)
}
SKIP_TO_NEXT_TTS_CHUNK_COMMAND -> {
handleSkipTtsChunk(direction = 1)
}
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
fun canSkipToPreviousChunk(): Boolean {
return canSkipTtsChunk(direction = -1)
}
fun canSkipToNextChunk(): Boolean {
return canSkipTtsChunk(direction = 1)
}
fun skipToPreviousChunkFromTransport() {
handleSkipTtsChunk(direction = -1)
}
fun skipToNextChunkFromTransport() {
handleSkipTtsChunk(direction = 1)
}
private fun canSkipTtsChunk(direction: Int): Boolean {
val currentIndex = currentChunkIndexFromPlayer()
.takeIf { it != C.INDEX_UNSET }
?: _ttsState.value.currentChunkIndex
return resolveTtsChunkSkipTarget(currentIndex, textChunks.size, direction) != null
}
private fun handleSkipTtsChunk(direction: Int) {
val currentIndex = currentChunkIndexFromPlayer()
.takeIf { it != C.INDEX_UNSET }
?: _ttsState.value.currentChunkIndex
val targetIndex = resolveTtsChunkSkipTarget(currentIndex, textChunks.size, direction)
if (targetIndex == null) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Ignoring chunk skip. direction=$direction, current=$currentIndex, total=${textChunks.size}"
)
logChunkNavMain(
"skip-ignored-boundary",
"direction=$direction currentChunk=$currentIndex totalChunks=${textChunks.size}"
)
return
}
val shouldResumePlayback = player.playWhenReady || _ttsState.value.isPlaying
val targetChunk = textChunks[targetIndex]
val newGeneration = advancePlaybackGeneration()
cancelPrefetchWork()
preparationJob?.cancel()
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Skipping TTS chunk. direction=$direction, from=$currentIndex, to=$targetIndex, playWhenReady=$shouldResumePlayback"
)
logChunkNavMain(
"skip-request",
"direction=$direction fromChunk=$currentIndex targetChunk=$targetIndex resume=$shouldResumePlayback newGeneration=$newGeneration"
)
val targetPlaylistIndex = findReusablePlaylistIndexForChunk(targetIndex, direction)
if (targetPlaylistIndex != null) {
player.pause()
logChunkNavMain(
"skip-reuse-before-seek",
"direction=$direction targetChunk=$targetIndex targetPlaylistIndex=$targetPlaylistIndex"
)
_ttsState.value = _ttsState.value.copy(
isLoading = false,
isPlaying = false,
currentText = targetChunk.text,
errorMessage = null,
currentChunkIndex = targetIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(targetIndex),
sourceCfi = targetChunk.sourceCfi,
startOffsetInSource = targetChunk.startOffsetInSource,
currentWordSourceCfi = null,
currentWordStartOffset = -1,
sessionFinished = false
)
wordTrackingJob?.cancel()
player.seekTo(targetPlaylistIndex, 0L)
prefetchNextChunkAudio(targetIndex)
player.playWhenReady = shouldResumePlayback
logChunkNavMain(
"skip-reuse-after-seek",
"direction=$direction targetChunk=$targetIndex targetPlaylistIndex=$targetPlaylistIndex resume=$shouldResumePlayback"
)
return
}
player.pause()
onResetContext()
wordTrackingJob?.cancel()
logChunkNavMain(
"skip-rebuild-start",
"direction=$direction targetChunk=$targetIndex resume=$shouldResumePlayback"
)
_ttsState.value = _ttsState.value.copy(
isLoading = true,
isPlaying = false,
currentText = targetChunk.text,
errorMessage = null,
currentChunkIndex = targetIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(targetIndex),
sourceCfi = targetChunk.sourceCfi,
startOffsetInSource = targetChunk.startOffsetInSource,
currentWordSourceCfi = null,
currentWordStartOffset = -1,
sessionFinished = false
)
deferTransitionPrefetchForGeneration(newGeneration)
preparationJob = scope.launch {
try {
logChunkNav(
"skip-rebuild-prepare-job-start",
"targetChunk=$targetIndex resume=$shouldResumePlayback"
)
prepareAndPlayFirstChunk(
startAtIndex = targetIndex,
playWhenReady = shouldResumePlayback,
prefetchAfterPrepare = false
)
if (!isPlaybackGenerationActive(newGeneration)) {
logChunkNav(
"skip-rebuild-stale-after-prepare",
"targetChunk=$targetIndex generation=$newGeneration currentGeneration=${currentPlaybackGeneration()}"
)
return@launch
}
clearAudioFilesExcept(retainedChunkIndices = setOf(targetIndex))
if (!isPlaybackGenerationActive(newGeneration)) {
logChunkNav(
"skip-rebuild-stale-after-cleanup",
"targetChunk=$targetIndex generation=$newGeneration currentGeneration=${currentPlaybackGeneration()}"
)
return@launch
}
logChunkNav(
"skip-rebuild-cleanup-complete",
"targetChunk=$targetIndex retained=[$targetIndex]"
)
releaseTransitionPrefetchForGeneration(newGeneration)
prefetchNextChunkAudio(targetIndex)
} finally {
releaseTransitionPrefetchForGeneration(newGeneration)
}
}
}
private fun handleSliceAndReload() {
val currentIdx = currentChunkIndexFromPlayer()
if (currentIdx == C.INDEX_UNSET) return
player.pause()
_ttsState.value = _ttsState.value.copy(isLoading = true)
advancePlaybackGeneration()
onResetContext()
val offset = _ttsState.value.currentWordStartOffset
val currentChunk = textChunks.getOrNull(currentIdx) ?: return
preparationJob?.cancel()
wordTrackingJob?.cancel()
player.stop()
player.clearMediaItems()
cancelPrefetchWork()
preparationJob = scope.launch {
clearAudioFiles()
loadedChunks.clear()
if (offset == -1) {
prepareAndPlayFirstChunk(startAtIndex = currentIdx, playWhenReady = false)
return@launch
}
val relativeOffset = (offset - currentChunk.startOffsetInSource).coerceIn(0, currentChunk.text.length)
if (relativeOffset >= currentChunk.text.length) {
if (currentIdx + 1 < textChunks.size) {
prepareAndPlayFirstChunk(startAtIndex = currentIdx + 1, playWhenReady = false)
}
return@launch
}
val slicedText = currentChunk.text.substring(relativeOffset)
val newChunk = currentChunk.copy(
text = slicedText,
startOffsetInSource = offset,
spokenText = slicedText,
)
val mutableChunks = textChunks.toMutableList()
mutableChunks[currentIdx] = newChunk
textChunks = mutableChunks.toList()
prepareAndPlayFirstChunk(startAtIndex = currentIdx, playWhenReady = false)
}
}
private fun handleChangeTtsMode(newMode: TtsMode) {
if (currentTtsMode == newMode) return
currentTtsMode = newMode
_ttsState.value = _ttsState.value.copy(ttsMode = newMode.name)
Timber.d("TTS Mode changed to $newMode (pending next start)")
}
private fun handleStartTts(
chunks: List<TtsChunk>,
speakerId: String,
bookId: String?,
bookTitle: String?,
chapterTitle: String?,
coverImageUri: String?,
chapterIndex: Int?,
totalChapters: Int?,
pageIndex: Int?,
startChunkIndex: Int,
ttsMode: TtsMode,
playbackSource: String?,
args: Bundle // Added this parameter
) {
if (chunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(errorMessage = appContext.getString(R.string.tts_error_no_text))
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("handleStartTts aborted because chunks is empty.")
return
}
// --- YOUR SNIPPET START ---
val authToken = args.getString(KEY_AUTH_TOKEN)
val continueSession = args.getBoolean(KEY_CONTINUE_SESSION, false)
val speed = args.getFloat("playback_speed", 1f)
val pitch = args.getFloat("playback_pitch", 1f)
scope.launch(Dispatchers.Main) {
if (ttsMode == TtsMode.CLOUD) {
player.playbackParameters = androidx.media3.common.PlaybackParameters(speed, pitch)
} else {
player.playbackParameters = androidx.media3.common.PlaybackParameters(1f, 1f)
}
}
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"handleStartTts. continueSession=$continueSession, chunks=${chunks.size}, startChunkIndex=$startChunkIndex, book='${bookTitle.orEmpty().take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters, mode=$ttsMode, playbackSource=$playbackSource"
)
if (!continueSession) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("New TTS session. Calling handleStopTts(clearState=false) before start.")
handleStopTts(clearState = false)
}
val startGeneration = advancePlaybackGeneration()
logChunkNav(
"start-session",
"chunks=${chunks.size} startChunk=$startChunkIndex resolvedStart=${resolveTtsStartChunkIndex(startChunkIndex, chunks.size)} continueSession=$continueSession generation=$startGeneration"
)
onPlaybackSessionPreparing(bookTitle, chapterTitle)
val effectiveSpeakerId = normalizeTtsSpeakerId(speakerId)
textChunks = chunks
currentSpeakerId = effectiveSpeakerId
currentTtsMode = ttsMode
this.bookId = bookId
this.bookTitle = bookTitle
this.chapterTitle = chapterTitle
this.coverImageUri = coverImageUri
this.chapterIndex = chapterIndex
this.totalChapters = totalChapters
this.pageIndex = pageIndex
loadedChunks.clear()
lastPrefetchIndex = -1
_ttsState.value = TtsState(
isLoading = true,
bookId = bookId,
bookTitle = bookTitle,
chapterTitle = chapterTitle,
chapterIndex = chapterIndex,
totalChapters = totalChapters,
pageIndex = pageIndex,
currentChunkIndex = -1,
totalChunks = chunks.size,
bookProgressPercent = calculateBookProgressPercent(-1),
speakerId = effectiveSpeakerId,
playbackSource = playbackSource,
ttsMode = ttsMode.name,
currentText = if (continueSession) _ttsState.value.currentText else null
)
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"TTS state set to loading. bookProgress=${_ttsState.value.bookProgressPercent}, currentTextRetained=${_ttsState.value.currentText != null}"
)
if (continueSession) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Continuation start. Cancelling prefetch/tracking but keeping player session alive until replacement media is ready.")
preparationJob?.cancel()
wordTrackingJob?.cancel()
prefetchLoopJob?.cancel()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
clearPlaylistForContinuation()
}
currentAuthToken = authToken
val resolvedStartChunkIndex = resolveTtsStartChunkIndex(startChunkIndex, chunks.size)
preparationJob = scope.launch {
prepareAndPlayFirstChunk(startAtIndex = resolvedStartChunkIndex)
}
}
fun forceStopWithError(errorMessage: String) {
scope.launch(Dispatchers.Main) {
_ttsState.value = _ttsState.value.copy(
isLoading = false,
isPlaying = false,
errorMessage = errorMessage
)
handleStopTts(clearState = false)
}
}
private fun handleChangeSpeaker(newSpeakerId: String) {
val safeSpeakerId = normalizeTtsSpeakerId(newSpeakerId)
if (currentSpeakerId == safeSpeakerId) return
currentSpeakerId = safeSpeakerId
saveTtsSpeaker(appContext, safeSpeakerId)
_ttsState.value = _ttsState.value.copy(speakerId = safeSpeakerId)
Timber.d("Speaker changed to $safeSpeakerId (pending next start)")
}
private fun currentChunkIndexFromPlayer(): Int {
return player.currentMediaItem?.mediaId?.toIntOrNull()
?: player.currentMediaItemIndex
}
private fun findPlaylistIndexForChunk(chunkIndex: Int): Int? {
for (index in 0 until player.mediaItemCount) {
if (player.getMediaItemAt(index).mediaId.toIntOrNull() == chunkIndex) {
return index
}
}
return null
}
private fun findReusablePlaylistIndexForChunk(chunkIndex: Int, direction: Int): Int? {
return resolveReusableTtsPlaylistIndex(findPlaylistIndexForChunk(chunkIndex), direction)
}
private fun seekToChunkMediaItem(chunkIndex: Int): Boolean {
val playlistIndex = findPlaylistIndexForChunk(chunkIndex) ?: return false
logChunkNavMain(
"seek-to-chunk",
"chunk=$chunkIndex playlistIndex=$playlistIndex"
)
player.seekTo(playlistIndex, 0L)
return true
}
private fun advanceToNextChunkMediaItem(currentChunkIndex: Int): Boolean {
val nextChunkIndex = resolveTtsChunkSkipTarget(currentChunkIndex, textChunks.size, direction = 1)
?: run {
logChunkNavMain(
"advance-next-no-target",
"currentChunk=$currentChunkIndex totalChunks=${textChunks.size}"
)
return false
}
val nextPlaylistIndex = findPlaylistIndexForChunk(nextChunkIndex)
?: run {
logChunkNavMain(
"advance-next-missing-playlist-item",
"currentChunk=$currentChunkIndex expectedNextChunk=$nextChunkIndex"
)
return false
}
val nextPlaylistChunkIndex = player.getMediaItemAt(nextPlaylistIndex).mediaId.toIntOrNull()
if (!shouldAdvanceToTtsPlaylistChunk(currentChunkIndex, nextPlaylistChunkIndex)) {
logChunkNavWarnMain(
"advance-next-refused-non-contiguous",
"Refusing non-contiguous TTS advance. current=$currentChunkIndex, nextPlaylistChunk=$nextPlaylistChunkIndex"
)
return false
}
logChunkNavMain(
"advance-next-seek",
"currentChunk=$currentChunkIndex nextChunk=$nextChunkIndex nextPlaylistIndex=$nextPlaylistIndex"
)
player.seekTo(nextPlaylistIndex, 0L)
return true
}
fun isCurrentChunkStreaming(): Boolean {
return player.currentMediaItem?.localConfiguration?.uri?.scheme == "ttsstream"
}
fun currentChunkDurationForNotification(currentPositionMs: Long): Long {
val mediaItem = player.currentMediaItem ?: return C.TIME_UNSET
if (mediaItem.localConfiguration?.uri?.scheme != "ttsstream") {
return C.TIME_UNSET
}
val streamId = mediaItem.localConfiguration?.uri?.host
?: mediaItem.localConfiguration?.uri?.lastPathSegment
if (streamId != null) {
val (isFinished, totalBytes) = StreamRegistry.getStreamMetadata(streamId)
if (isFinished) {
val durationMs = resolveTtsStreamPcmDurationMs(totalBytes)
if (durationMs != null) {
return durationMs.coerceAtLeast(currentPositionMs.coerceAtLeast(0L))
}
}
}
val chunkIndex = mediaItem.mediaId.toIntOrNull() ?: currentChunkIndexFromPlayer()
val chunk = textChunks.getOrNull(chunkIndex)
val text = chunk?.spokenText?.ifBlank { chunk.text }
?: mediaItem.mediaMetadata.extras?.getString("ttsText")
?: mediaItem.mediaMetadata.subtitle?.toString()
?: return C.TIME_UNSET
return estimateTtsNotificationDurationMs(text, currentPositionMs) ?: C.TIME_UNSET
}
private fun calculateBookProgressPercent(chunkIndex: Int): Int? {
val chapter = chapterIndex ?: return null
val chapterCount = totalChapters?.takeIf { it > 0 } ?: return null
val safeChunkProgress = if (textChunks.isNotEmpty() && chunkIndex >= 0) {
((chunkIndex + 1).toDouble() / textChunks.size.toDouble()).coerceIn(0.0, 1.0)
} else {
0.0
}
return (((chapter.toDouble() + safeChunkProgress) / chapterCount.toDouble()) * 100.0)
.roundToInt()
.coerceIn(0, 100)
}
private fun markSessionFinishedNaturally(chunkIndex: Int) {
val currentState = _ttsState.value
if (currentState.isLoading && currentState.currentChunkIndex == -1) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
"Ignoring stale streamed completion while a continuation session is loading."
)
return
}
val safeChunkIndex = if (textChunks.isNotEmpty()) {
chunkIndex.coerceIn(0, textChunks.lastIndex)
} else {
-1
}
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i(
"Setting sessionFinished = true for naturally completed streamed TTS. chunk=$safeChunkIndex, totalChunks=${textChunks.size}"
)
_ttsState.value = _ttsState.value.copy(
isPlaying = false,
isLoading = false,
currentChunkIndex = safeChunkIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(safeChunkIndex),
currentWordSourceCfi = null,
currentWordStartOffset = -1,
sessionFinished = true
)
}
private fun clearPlaylistForContinuation() {
val filesToDelete = audioFiles.values.toList()
val streamsToRemove = chunkStreamIds.values.toList()
audioFiles.clear()
chunkStreamIds.clear()
loadedChunks.clear()
lastPrefetchIndex = -1
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Cleared continuation temp resources. oldFiles=${filesToDelete.size}, oldStreams=${streamsToRemove.size}"
)
scope.launch(Dispatchers.IO) {
filesToDelete.forEach { deleteTempFile(it) }
streamsToRemove.forEach { StreamRegistry.remove(it) }
}
}
private suspend fun prepareAndPlayFirstChunk(
startAtIndex: Int = 0,
playWhenReady: Boolean = true,
startAtPosition: Long = 0L,
prefetchAfterPrepare: Boolean = true
) {
val generation = currentPlaybackGeneration()
val firstChunk = textChunks.getOrNull(startAtIndex)
if (firstChunk == null) {
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = appContext.getString(R.string.tts_error_starting_playback))
onPlaybackSessionStopped()
return
}
val chunkStartTime = System.currentTimeMillis()
Timber.tag("TTS_CLOUD_DIAG").i("Starting audio generation for first chunk (index=$startAtIndex).")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Preparing first chunk. startAtIndex=$startAtIndex, playWhenReady=$playWhenReady"
)
logChunkNav(
"prepare-first-start",
"chunk=$startAtIndex resume=$playWhenReady startPosition=$startAtPosition prefetchAfterPrepare=$prefetchAfterPrepare generation=$generation"
)
val spokenText = firstChunk.spokenText.ifBlank { firstChunk.text }
val ttsAudioData = generateAudioChunk(bookTitle ?: appContext.getString(R.string.tts_unknown_book), chapterTitle, startAtIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
logChunkNav(
"prepare-first-generated",
"chunk=$startAtIndex elapsedMs=${System.currentTimeMillis() - chunkStartTime} audioFile=${ttsAudioData.audioFile?.name} streamUri=${ttsAudioData.streamUri} error=${ttsAudioData.error}"
)
if (!isPlaybackGenerationActive(generation)) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Ignoring stale prepared TTS chunk. chunk=$startAtIndex, generation=$generation, currentGeneration=${currentPlaybackGeneration()}"
)
logChunkNav(
"prepare-first-stale",
"chunk=$startAtIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}"
)
cleanupGeneratedAudioData(ttsAudioData)
return
}
if (ttsAudioData.error == "INSUFFICIENT_CREDITS") {
withContext(Dispatchers.Main) {
if (!isPlaybackGenerationActive(generation)) return@withContext
_ttsState.value = _ttsState.value.copy(isLoading = false, isPlaying = false, errorMessage = "INSUFFICIENT_CREDITS")
handleStopTts(clearState = false)
}
return
}
val audioFile = ttsAudioData.audioFile
val streamUri = ttsAudioData.streamUri
val serverText = ttsAudioData.serverText
if ((audioFile != null || streamUri != null) && serverText != null) {
if (audioFile != null) {
audioFiles[startAtIndex] = audioFile
}
loadedChunks.add(startAtIndex)
val updatedChunk = processWordTimings(firstChunk, serverText, ttsAudioData.wordTimings)
val mutableChunks = textChunks.toMutableList()
mutableChunks[startAtIndex] = updatedChunk
textChunks = mutableChunks.toList()
if (streamUri != null) {
val uriStr = streamUri.toUri()
val id = uriStr.host ?: uriStr.lastPathSegment
if (id != null) chunkStreamIds[startAtIndex] = id
}
val pathToUse = streamUri ?: audioFile!!.absolutePath
val mediaItem = createMediaItem(updatedChunk.text, pathToUse, startAtIndex, updatedChunk)
withContext(Dispatchers.Main) {
if (!isPlaybackGenerationActive(generation)) {
logChunkNavMain(
"prepare-first-stale-main",
"chunk=$startAtIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}"
)
cleanupGeneratedAudioData(ttsAudioData)
return@withContext
}
val prepStartTime = System.currentTimeMillis()
logChunkNavMain(
"prepare-first-set-media-before",
"chunk=$startAtIndex mediaId=${mediaItem.mediaId} resume=$playWhenReady"
)
player.setMediaItem(mediaItem)
player.prepare()
if (startAtPosition > 0) {
player.seekTo(startAtPosition)
}
player.playWhenReady = playWhenReady
Timber.tag("TTS_CLOUD_DIAG").i("ExoPlayer setMediaItem & prepare called in ${System.currentTimeMillis() - prepStartTime}ms")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Player prepared for TTS. mediaId=${mediaItem.mediaId}, title='${mediaItem.mediaMetadata.title}', playWhenReady=${player.playWhenReady}, playbackState=${player.playbackState}, mediaItems=${player.mediaItemCount}"
)
logChunkNavMain(
"prepare-first-set-media-after",
"chunk=$startAtIndex mediaId=${mediaItem.mediaId} resume=$playWhenReady prepMs=${System.currentTimeMillis() - prepStartTime}"
)
_ttsState.value = _ttsState.value.copy(
isLoading = false,
isPlaying = playWhenReady,
currentText = updatedChunk.text,
chapterTitle = chapterTitle,
chapterIndex = chapterIndex,
totalChapters = totalChapters,
currentChunkIndex = startAtIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(startAtIndex),
sessionFinished = false,
sourceCfi = updatedChunk.sourceCfi,
startOffsetInSource = updatedChunk.startOffsetInSource
)
}
if (!isPlaybackGenerationActive(generation)) return
if (prefetchAfterPrepare) {
logChunkNav(
"prepare-first-prefetch-request",
"chunk=$startAtIndex"
)
prefetchNextChunkAudio(startAtIndex)
}
} else {
logChunkNav(
"prepare-first-failed",
"chunk=$startAtIndex error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
)
_ttsState.value = _ttsState.value.copy(
isLoading = false,
errorMessage = ttsAudioData.error ?: appContext.getString(R.string.tts_error_load_audio)
)
onPlaybackSessionStopped()
}
}
private fun processWordTimings(
originalChunk: TtsChunk,
@Suppress("unused") serverText: String,
wordTimings: List<WordTimingInfo>?
): TtsChunk {
if (wordTimings.isNullOrEmpty()) {
return originalChunk
}
if (originalChunk.spokenText != originalChunk.text) {
return originalChunk.copy(timedWords = emptyList())
}
val timedWords = mutableListOf<TimedWord>()
var currentSearchIndex = 0
wordTimings.forEach { timingInfo ->
val wordIndex = originalChunk.text.indexOf(timingInfo.word, startIndex = currentSearchIndex, ignoreCase = false)
if (wordIndex != -1) {
timedWords.add(
TimedWord(
word = timingInfo.word,
startTime = timingInfo.startTime,
startOffset = originalChunk.startOffsetInSource + wordIndex
)
)
currentSearchIndex = wordIndex + timingInfo.word.length
} else {
Timber.w("Could not find server word '${timingInfo.word}' in original chunk text")
}
}
return originalChunk.copy(timedWords = timedWords)
}
private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) {
Timber.tag("TTS_CLOUD_DIAG").d("handleStopTts called. clearState=$clearState, userInitiated=$userInitiated")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"handleStopTts. clearState=$clearState, userInitiated=$userInitiated"
)
advancePlaybackGeneration()
onPlaybackSessionStopped()
onResetContext()
preparationJob?.cancel()
wordTrackingJob?.cancel()
if (clearState) {
val finalState = TtsState(
sessionEndedByStop = userInitiated,
speakerId = currentSpeakerId,
ttsMode = currentTtsMode.name
)
_ttsState.value = finalState
updateSessionControls(finalState)
}
player.stop()
player.clearMediaItems()
textChunks = emptyList()
bookId = null
bookTitle = null
chapterTitle = null
coverImageUri = null
chapterIndex = null
totalChapters = null
pageIndex = null
cancelPrefetchWork()
loadedChunks.clear()
scope.launch {
clearAudioFiles()
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val newPlaylistIndex = player.currentMediaItemIndex
Timber.tag("TTS_CLOUD_DIAG").d("onMediaItemTransition to playlistIndex: $newPlaylistIndex, mediaId: ${mediaItem?.mediaId}, reason: $reason")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onMediaItemTransition. playlistIndex=$newPlaylistIndex, mediaId=${mediaItem?.mediaId}, reason=$reason, title='${mediaItem?.mediaMetadata?.title}', playbackState=${player.playbackState}, isPlaying=${player.isPlaying}"
)
logChunkNavMain(
"media-transition",
"reason=$reason playlistIndex=$newPlaylistIndex mediaId=${mediaItem?.mediaId}"
)
if (newPlaylistIndex == C.INDEX_UNSET) return
val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
val extras = mediaItem.mediaMetadata.extras
val newText = extras?.getString("ttsText") ?: mediaItem.mediaMetadata.subtitle?.toString()
val sourceCfi = extras?.getString("sourceCfi")
val startOffset = extras?.getInt("startOffset", -1) ?: -1
_ttsState.value = _ttsState.value.copy(
currentText = newText,
chapterTitle = chapterTitle,
chapterIndex = chapterIndex,
totalChapters = totalChapters,
currentChunkIndex = currentChunkIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
sessionFinished = false,
sourceCfi = sourceCfi,
startOffsetInSource = startOffset
)
wordTrackingJob?.cancel()
if (player.isPlaying) {
wordTrackingJob = scope.launch {
trackWordByWord()
}
}
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO && newPlaylistIndex > 0) {
val previousMediaItem = player.getMediaItemAt(newPlaylistIndex - 1)
val previousChunkIndex = previousMediaItem.mediaId.toIntOrNull()
if (previousChunkIndex != null) {
logChunkNavMain(
"media-transition-clean-previous",
"currentChunk=$currentChunkIndex previousChunk=$previousChunkIndex previousPlaylistIndex=${newPlaylistIndex - 1}"
)
scope.launch(Dispatchers.IO) {
val file = audioFiles.remove(previousChunkIndex)
deleteTempFile(file)
loadedChunks.remove(previousChunkIndex)
val streamId = chunkStreamIds.remove(previousChunkIndex)
if (streamId != null) {
StreamRegistry.remove(streamId)
}
}
}
}
if (!canStartTransitionPrefetch()) {
logChunkNavMain(
"media-transition-prefetch-deferred",
"currentChunk=$currentChunkIndex generation=${currentPlaybackGeneration()} deferredGeneration=${deferredTransitionPrefetchGeneration.get()}"
)
return
}
prefetchNextChunkAudio(currentChunkIndex)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onIsPlayingChanged. isPlaying=$isPlaying, playbackState=${player.playbackState}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
)
logChunkNavMain(
"is-playing-changed",
"isPlaying=$isPlaying"
)
var nextState = _ttsState.value.copy(isPlaying = isPlaying)
if (isPlaying) {
if (nextState.isLoading) {
nextState = nextState.copy(isLoading = false)
}
wordTrackingJob?.cancel()
wordTrackingJob = scope.launch {
trackWordByWord()
}
} else {
wordTrackingJob?.cancel()
nextState = nextState.copy(
currentWordSourceCfi = null,
currentWordStartOffset = -1
)
val currentChunkIndex = currentChunkIndexFromPlayer()
val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1
if (player.playbackState == Player.STATE_ENDED) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d("ExoPlayer STATE_ENDED. currentChunkIndex: $currentChunkIndex, isLastChunk: $isLastChunkInSession, totalChunks: ${textChunks.size}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Player reached ENDED. currentChunkIndex=$currentChunkIndex, isLastChunk=$isLastChunkInSession, totalChunks=${textChunks.size}, sessionFinishedWillBeSet=${isLastChunkInSession || textChunks.isEmpty()}"
)
logChunkNavMain(
"player-state-ended",
"currentChunk=$currentChunkIndex isLast=$isLastChunkInSession totalChunks=${textChunks.size}"
)
if (isLastChunkInSession || textChunks.isEmpty()) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true")
logChunkNavMain(
"player-ended-session-finished",
"currentChunk=$currentChunkIndex totalChunks=${textChunks.size}"
)
nextState = nextState.copy(
currentChunkIndex = currentChunkIndex,
totalChunks = textChunks.size,
bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
sessionFinished = true
)
} else {
val nextIdx = currentChunkIndex + 1
val isPrefetching = prefetchingJobs.containsKey(nextIdx)
if (!isPrefetching) {
Timber.w("BUFFERING: Stalled at chunk $currentChunkIndex. Restarting prefetch for $nextIdx.")
logChunkNavMain(
"player-ended-prefetch-restart",
"currentChunk=$currentChunkIndex expectedNext=$nextIdx isPrefetching=$isPrefetching"
)
prefetchNextChunkAudio(currentChunkIndex)
}
logChunkNavMain(
"player-ended-waiting-next",
"currentChunk=$currentChunkIndex expectedNext=$nextIdx isPrefetching=$isPrefetching"
)
nextState = nextState.copy(isLoading = true)
}
}
}
_ttsState.value = nextState
if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
if (!nextState.sessionEndedByStop && !nextState.isLoading && preparationJob?.isActive != true) {
Timber.tag("TTS_CLOUD_DIAG").d("Auto-stopping TTS from onIsPlayingChanged (IDLE and not loading)")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Auto-stopping from IDLE/not-loading path.")
handleStopTts(userInitiated = true)
} else {
Timber.tag("TTS_CLOUD_DIAG").d("Ignoring STATE_IDLE in onIsPlayingChanged because isLoading=${nextState.isLoading}, preparationJob.isActive=${preparationJob?.isActive}")
}
}
}
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Timber.tag("TTS_CLOUD_DIAG").e(error, "Player error: [${error.errorCodeName}] ${error.message}")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(error, "Player error. code=${error.errorCodeName}, message=${error.message}")
_ttsState.value = _ttsState.value.copy(errorMessage = appContext.getString(R.string.tts_error_playback, error.message.orEmpty()))
handleStopTts(userInitiated = true)
}
private fun prefetchNextChunkAudio(currentIndex: Int) {
if (currentIndex == lastPrefetchIndex && prefetchLoopJob?.isActive == true) {
logChunkNav(
"prefetch-skip-existing-loop",
"currentChunk=$currentIndex generation=${currentPlaybackGeneration()}"
)
return
}
lastPrefetchIndex = currentIndex
val generation = currentPlaybackGeneration()
logChunkNav(
"prefetch-loop-start",
"currentChunk=$currentIndex generation=$generation"
)
prefetchLoopJob?.cancel()
prefetchLoopJob = scope.launch {
for (i in 1..PREFETCH_LOOKAHEAD) {
if (!isPlaybackGenerationActive(generation)) {
logChunkNav(
"prefetch-loop-stale",
"currentChunk=$currentIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}"
)
return@launch
}
val targetIndex = currentIndex + i
if (targetIndex < textChunks.size) {
if (prefetchingJobs.containsKey(targetIndex)) {
logChunkNav("prefetch-target-skip-inflight", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
continue
}
if (audioFiles.containsKey(targetIndex)) {
logChunkNav("prefetch-target-skip-audio-cache", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
continue
}
if (loadedChunks.contains(targetIndex)) {
logChunkNav("prefetch-target-skip-loaded", "currentChunk=$currentIndex targetChunk=$targetIndex generation=$generation")
continue
}
Timber.d("PlaybackManager: Scheduling prefetch for chunk $targetIndex")
logChunkNav(
"prefetch-target-schedule",
"currentChunk=$currentIndex targetChunk=$targetIndex lookahead=$i generation=$generation"
)
val job = launch {
val nextChunk = textChunks[targetIndex]
val prefetchStartTime = System.currentTimeMillis()
Timber.tag("TTS_CLOUD_DIAG").i("Starting prefetch generation for chunk $targetIndex")
logChunkNav(
"prefetch-generate-start",
"targetChunk=$targetIndex generation=$generation"
)
val spokenText = nextChunk.spokenText.ifBlank { nextChunk.text }
val ttsAudioData = generateAudioChunk(bookTitle ?: appContext.getString(R.string.tts_unknown_book), chapterTitle, targetIndex, textChunks.size, spokenText, currentSpeakerId, currentTtsMode, currentAuthToken)
Timber.tag("TTS_CLOUD_DIAG").i("Prefetch audio setup for chunk $targetIndex took ${System.currentTimeMillis() - prefetchStartTime}ms")
logChunkNav(
"prefetch-generate-complete",
"targetChunk=$targetIndex generation=$generation elapsedMs=${System.currentTimeMillis() - prefetchStartTime} audioFile=${ttsAudioData.audioFile?.name} streamUri=${ttsAudioData.streamUri} error=${ttsAudioData.error}"
)
if (!isPlaybackGenerationActive(generation)) {
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"Ignoring stale prefetched TTS chunk. chunk=$targetIndex, generation=$generation, currentGeneration=${currentPlaybackGeneration()}"
)
logChunkNav(
"prefetch-generate-stale",
"targetChunk=$targetIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}"
)
cleanupGeneratedAudioData(ttsAudioData)
return@launch
}
if (ttsAudioData.error == "INSUFFICIENT_CREDITS") {
withContext(Dispatchers.Main) {
if (!isPlaybackGenerationActive(generation)) return@withContext
_ttsState.value = _ttsState.value.copy(isLoading = false, isPlaying = false, errorMessage = "INSUFFICIENT_CREDITS")
handleStopTts(clearState = false)
}
return@launch
}
val audioFile = ttsAudioData.audioFile
val streamUri = ttsAudioData.streamUri
val serverText = ttsAudioData.serverText
if ((audioFile != null || streamUri != null) && serverText != null) {
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
val pathToUse = streamUri ?: audioFile!!.absolutePath
val nextMediaItem = createMediaItem(updatedChunk.text, pathToUse, targetIndex, updatedChunk)
withContext(Dispatchers.Main) {
if (!isPlaybackGenerationActive(generation)) {
logChunkNavMain(
"prefetch-add-stale-main",
"targetChunk=$targetIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}"
)
cleanupGeneratedAudioData(ttsAudioData)
return@withContext
}
if (audioFile != null) {
audioFiles[targetIndex] = audioFile
}
loadedChunks.add(targetIndex)
val mutableChunks = textChunks.toMutableList()
mutableChunks[targetIndex] = updatedChunk
textChunks = mutableChunks.toList()
if (streamUri != null) {
val uriStr = streamUri.toUri()
val id = uriStr.host ?: uriStr.lastPathSegment
if (id != null) chunkStreamIds[targetIndex] = id
}
val wasLoading = _ttsState.value.isLoading
var exists = false
for (k in 0 until player.mediaItemCount) {
if (player.getMediaItemAt(k).mediaId == targetIndex.toString()) {
exists = true
break
}
}
if (!exists) {
var insertPosition = player.mediaItemCount
for (k in 0 until player.mediaItemCount) {
val id = player.getMediaItemAt(k).mediaId.toIntOrNull() ?: -1
if (id > targetIndex) {
insertPosition = k
break
}
}
logChunkNavMain(
"prefetch-add-before",
"targetChunk=$targetIndex insertPosition=$insertPosition exists=false generation=$generation"
)
player.addMediaItem(insertPosition, nextMediaItem)
logChunkNavMain(
"prefetch-add-after",
"targetChunk=$targetIndex insertPosition=$insertPosition generation=$generation"
)
} else {
logChunkNavMain(
"prefetch-add-skip-existing-playlist",
"targetChunk=$targetIndex generation=$generation"
)
}
val currentChunkIndex = currentChunkIndexFromPlayer()
val isImmediateNextChunk = targetIndex == currentChunkIndex + 1
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) {
logChunkNavMain(
"prefetch-ended-immediate-next",
"currentChunk=$currentChunkIndex targetChunk=$targetIndex generation=$generation"
)
if (seekToChunkMediaItem(targetIndex)) {
player.play()
}
} else if (wasLoading && isImmediateNextChunk) {
logChunkNavMain(
"prefetch-loading-resolved",
"currentChunk=$currentChunkIndex targetChunk=$targetIndex generation=$generation"
)
_ttsState.value = _ttsState.value.copy(isLoading = false)
}
}
} else {
Timber.e("Prefetch: Failed to download chunk $targetIndex")
logChunkNav(
"prefetch-generate-failed",
"targetChunk=$targetIndex generation=$generation error=${ttsAudioData.error} audioFile=${audioFile?.name} streamUri=$streamUri serverText=${serverText != null}"
)
}
}
prefetchingJobs[targetIndex] = job
job.invokeOnCompletion {
prefetchingJobs.remove(targetIndex, job)
}
job.join()
if (!isPlaybackGenerationActive(generation)) {
logChunkNav(
"prefetch-after-join-stale",
"targetChunk=$targetIndex generation=$generation currentGeneration=${currentPlaybackGeneration()}"
)
return@launch
}
if (!loadedChunks.contains(targetIndex) && findPlaylistIndexForChunk(targetIndex) == null) {
logChunkNavWarnMain(
"prefetch-stop-after-missing-chunk",
"Stopping TTS prefetch after missing chunk $targetIndex to keep playlist contiguous."
)
return@launch
}
}
}
logChunkNav(
"prefetch-loop-complete",
"currentChunk=$currentIndex generation=$generation"
)
}
}
private suspend fun trackWordByWord() {
var loopCount = 0
while (true) {
val currentIdx = withContext(Dispatchers.Main) { player.currentMediaItemIndex }
val currentMediaItem = withContext(Dispatchers.Main) { player.currentMediaItem } ?: break
val playbackPosition = withContext(Dispatchers.Main) { player.currentPosition }
if (loopCount % 20 == 0) {
withContext(Dispatchers.Main) { player.playbackState }
withContext(Dispatchers.Main) { player.isPlaying }
}
val uri = currentMediaItem.localConfiguration?.uri
if (uri?.scheme == "ttsstream") {
val streamId = uri.host ?: uri.lastPathSegment
if (streamId != null) {
val (isFinished, totalBytes) = StreamRegistry.getStreamMetadata(streamId)
if (isFinished && totalBytes > 44) {
val expectedDurationMs = (totalBytes - 44) / 48
if (playbackPosition >= expectedDurationMs) {
Timber.tag("TTS_CLOUD_DIAG").i("Stream finished naturally: pos=$playbackPosition, expected=$expectedDurationMs. Transitioning.")
withContext(Dispatchers.Main) {
if (player.currentMediaItemIndex == currentIdx) {
val finishedChunkIndex = currentMediaItem.mediaId.toIntOrNull()
?: currentChunkIndexFromPlayer()
logChunkNavMain(
"stream-finished",
"finishedChunk=$finishedChunkIndex playlistIndex=$currentIdx playbackPosition=$playbackPosition expectedDuration=$expectedDurationMs streamId=$streamId"
)
if (advanceToNextChunkMediaItem(finishedChunkIndex)) {
logChunkNavMain(
"stream-finished-advanced",
"finishedChunk=$finishedChunkIndex"
)
player.play()
} else if (resolveTtsChunkSkipTarget(finishedChunkIndex, textChunks.size, direction = 1) != null) {
logChunkNavMain(
"stream-finished-next-missing-prefetch",
"finishedChunk=$finishedChunkIndex expectedNext=${finishedChunkIndex + 1}"
)
_ttsState.value = _ttsState.value.copy(isLoading = true)
prefetchNextChunkAudio(finishedChunkIndex)
} else {
logChunkNavMain(
"stream-finished-session-finished",
"finishedChunk=$finishedChunkIndex totalChunks=${textChunks.size}"
)
markSessionFinishedNaturally(finishedChunkIndex)
player.pause()
}
} else {
logChunkNavMain(
"stream-finished-stale-playlist-index",
"observedPlaylistIndex=$currentIdx currentPlaylistIndex=${player.currentMediaItemIndex} playbackPosition=$playbackPosition expectedDuration=$expectedDurationMs streamId=$streamId"
)
}
}
break
}
}
}
}
val extras = currentMediaItem.mediaMetadata.extras ?: break
val sourceCfi = extras.getString("sourceCfi") ?: break
val timestamps = extras.getDoubleArray(KEY_WORD_TIMESTAMPS)
val offsets = extras.getIntArray(KEY_WORD_OFFSETS)
if (timestamps != null && offsets != null) {
val currentWordIndex = timestamps.indexOfLast { (it * 1000).toLong() <= playbackPosition }
if (currentWordIndex != -1) {
val currentWordOffset = offsets[currentWordIndex]
if (_ttsState.value.currentWordStartOffset != currentWordOffset || _ttsState.value.currentWordSourceCfi != sourceCfi) {
_ttsState.value = _ttsState.value.copy(
currentWordSourceCfi = sourceCfi,
currentWordStartOffset = currentWordOffset
)
}
}
}
delay(50)
loopCount++
}
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
Timber.tag("TTS_CLOUD_DIAG").d("onPlayWhenReadyChanged: playWhenReady=$playWhenReady, reason=$reason")
}
override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) {
Timber.tag("TTS_CLOUD_DIAG").d("onPositionDiscontinuity: reason=$reason")
}
private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem {
val isStreaming = path.startsWith("ttsstream://")
val localAudioFile = if (isStreaming) null else File(path)
val progress = calculateBookProgressPercent(index)
val chunkLabel = if (textChunks.isNotEmpty()) {
"Chunk ${index + 1}/${textChunks.size}"
} else {
null
}
val chapterLabel = buildString {
val chapter = chapterIndex
val chapterCount = totalChapters
if (chapter != null && chapterCount != null) {
append("Chapter ${chapter + 1} of $chapterCount")
if (!chapterTitle.isNullOrBlank()) append(": $chapterTitle")
} else if (!chapterTitle.isNullOrBlank()) {
append(chapterTitle)
}
if (progress != null) {
if (isNotEmpty()) append(" - ")
append("$progress%")
}
if (chunkLabel != null) {
if (isNotEmpty()) append(" - ")
append(chunkLabel)
}
}.ifBlank { chapterTitle ?: chunkLabel ?: "TTS" }
val chunkPreview = text
.replace(Regex("\\s+"), " ")
.trim()
.take(180)
val extras = Bundle().apply {
putString("ttsText", text)
putString("sourceCfi", chunk.sourceCfi)
putInt("startOffset", chunk.startOffsetInSource)
putString("bookId", bookId)
putInt("pageIndex", pageIndex ?: -1)
if (chunk.timedWords.isNotEmpty()) {
val timestamps = chunk.timedWords.map { it.startTime }.toDoubleArray()
val offsets = chunk.timedWords.map { it.startOffset }.toIntArray()
putDoubleArray(KEY_WORD_TIMESTAMPS, timestamps)
putIntArray(KEY_WORD_OFFSETS, offsets)
}
}
val metadataBuilder = MediaMetadata.Builder()
.setTitle(bookTitle ?: chapterLabel)
.setDisplayTitle(bookTitle ?: chapterLabel)
.setArtist(chapterLabel)
.setSubtitle(chunkPreview)
.setDescription(chunkPreview)
.setArtworkUri(coverImageUri?.toUri())
.setTrackNumber(index + 1)
.setTotalTrackCount(textChunks.size)
.setExtras(extras)
val durationMs = if (isStreaming) {
estimateTtsNotificationDurationMs(text)
} else {
localAudioFile?.let(::resolveWavFileDurationMs)
}
durationMs?.let { metadataBuilder.setDurationMs(it) }
val metadata = metadataBuilder.build()
val uri = if (isStreaming) path.toUri() else Uri.fromFile(localAudioFile!!)
return MediaItem.Builder()
.setUri(uri)
.setMediaId(index.toString())
.setMediaMetadata(metadata)
.build()
}
private fun deleteTempFile(file: File?) {
file?.let {
if (it.name.startsWith("tts_audio_chunk_") || it.name.startsWith("base_tts_") || it.name.startsWith("tts_live_")) {
it.delete()
}
}
}
private fun cleanupGeneratedAudioData(ttsAudioData: TtsAudioData) {
deleteTempFile(ttsAudioData.audioFile)
val streamId = ttsAudioData.streamUri
?.toUri()
?.let { it.host ?: it.lastPathSegment }
if (streamId != null) {
StreamRegistry.remove(streamId)
}
}
private suspend fun clearAudioFiles() {
withContext(Dispatchers.IO) {
audioFiles.values.forEach { deleteTempFile(it) }
audioFiles.clear()
chunkStreamIds.values.forEach { StreamRegistry.remove(it) } // ADDED
chunkStreamIds.clear() // ADDED
loadedChunks.clear()
}
}
private suspend fun clearAudioFilesExcept(retainedChunkIndices: Set<Int>) {
withContext(Dispatchers.IO) {
val audioKeysToRemove = audioFiles.keys
.filter { it !in retainedChunkIndices }
.toList()
logChunkNav(
"clear-audio-except",
"retained=${retainedChunkIndices.sorted()} removeAudio=$audioKeysToRemove"
)
audioKeysToRemove.forEach { chunkIndex ->
val file = audioFiles.remove(chunkIndex)
deleteTempFile(file)
loadedChunks.remove(chunkIndex)
}
val streamKeysToRemove = chunkStreamIds.keys
.filter { it !in retainedChunkIndices }
.toList()
streamKeysToRemove.forEach { chunkIndex ->
val streamId = chunkStreamIds.remove(chunkIndex)
if (streamId != null) {
StreamRegistry.remove(streamId)
}
}
}
}
private fun updateSessionControls(state: TtsState) {
mediaSession?.let { session ->
session.setCustomLayout(createCustomLayout(state))
session.setMediaButtonPreferences(createNotificationButtons())
session.setSessionActivity(createSessionActivity(state))
}
}
private fun createCustomLayout(state: TtsState): List<CommandButton> {
return listOf(
createStateButton(state),
createPreviousChunkCommandButton(state),
createNextChunkCommandButton(state),
createStopCommandButton()
)
}
private fun createNotificationButtons(): List<CommandButton> {
return listOf(
createStopCommandButton()
)
}
@Suppress("Deprecation")
private fun createPreviousChunkCommandButton(state: TtsState): CommandButton {
val canSkip = resolveTtsChunkSkipTarget(
currentChunkIndex = state.currentChunkIndex,
totalChunks = state.totalChunks,
direction = -1
) != null
return CommandButton.Builder(CommandButton.ICON_PREVIOUS)
.setDisplayName(appContext.getString(R.string.content_desc_tts_previous_chunk))
.setSessionCommand(SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND)
.setIconResId(R.drawable.skip_previous)
.setEnabled(canSkip)
.build()
}
@Suppress("Deprecation")
private fun createNextChunkCommandButton(state: TtsState): CommandButton {
val canSkip = resolveTtsChunkSkipTarget(
currentChunkIndex = state.currentChunkIndex,
totalChunks = state.totalChunks,
direction = 1
) != null
return CommandButton.Builder(CommandButton.ICON_NEXT)
.setDisplayName(appContext.getString(R.string.content_desc_tts_next_chunk))
.setSessionCommand(SKIP_TO_NEXT_TTS_CHUNK_COMMAND)
.setIconResId(R.drawable.skip_next)
.setEnabled(canSkip)
.build()
}
private fun createSessionActivity(state: TtsState): PendingIntent {
val targetCfi = state.currentWordSourceCfi?.takeIf { it.isNotBlank() }
?: state.sourceCfi?.takeIf { it.isNotBlank() }
val targetOffset = state.currentWordStartOffset.takeIf { it >= 0 }
?: state.startOffsetInSource.takeIf { it >= 0 }
val intent = Intent(appContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (!state.bookId.isNullOrBlank()) {
action = ACTION_OPEN_TTS_SESSION
putExtra(EXTRA_TTS_BOOK_ID, state.bookId)
state.chapterIndex?.let { putExtra(EXTRA_TTS_CHAPTER_INDEX, it) }
state.pageIndex?.let { putExtra(EXTRA_TTS_PAGE_INDEX, it) }
targetCfi?.let { putExtra(EXTRA_TTS_SOURCE_CFI, it) }
targetOffset?.let { putExtra(EXTRA_TTS_START_OFFSET, it) }
} else {
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
}
}
return PendingIntent.getActivity(
appContext,
TTS_SESSION_ACTIVITY_REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
@Suppress("Deprecation")
private fun createStateButton(state: TtsState): CommandButton {
val bundle = Bundle().apply {
putBoolean("isLoading", state.isLoading)
putString("errorMessage", state.errorMessage)
putString("bookId", state.bookId)
putString("bookTitle", state.bookTitle)
putString("chapterTitle", state.chapterTitle)
putInt("chapterIndex", state.chapterIndex ?: -1)
putInt("totalChapters", state.totalChapters ?: -1)
putInt("pageIndex", state.pageIndex ?: -1)
putInt("currentChunkIndex", state.currentChunkIndex)
putInt("totalChunks", state.totalChunks)
putInt("bookProgressPercent", state.bookProgressPercent ?: -1)
putString("speakerId", state.speakerId)
putString("sourceCfi", state.sourceCfi)
putInt("startOffset", state.startOffsetInSource)
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
putString("currentWordSourceCfi", state.currentWordSourceCfi)
putInt("currentWordStartOffset", state.currentWordStartOffset)
putBoolean("sessionFinished", state.sessionFinished)
putString("playbackSource", state.playbackSource)
putString("ttsMode", state.ttsMode)
}
return CommandButton.Builder()
.setSessionCommand(STATE_UPDATE_COMMAND)
.setDisplayName("TtsState")
.setExtras(bundle)
.build()
}
@Suppress("Deprecation")
private fun createStopCommandButton(): CommandButton {
return CommandButton.Builder(CommandButton.ICON_STOP)
.setDisplayName("Stop TTS")
.setSessionCommand(STOP_TTS_COMMAND)
.setIconResId(R.drawable.close)
.build()
}
fun release() {
player.removeListener(this)
handleStopTts(userInitiated = true)
Timber.d("TtsPlaybackManager released.")
}
override fun onPlaybackStateChanged(playbackState: Int) {
val stateName = when (playbackState) {
Player.STATE_IDLE -> "STATE_IDLE"
Player.STATE_BUFFERING -> "STATE_BUFFERING"
Player.STATE_READY -> "STATE_READY"
Player.STATE_ENDED -> "STATE_ENDED"
else -> "UNKNOWN"
}
Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName")
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"onPlaybackStateChanged. state=$stateName, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
)
}
}