v1.0.47 (#279)
* Add performance and stylus debugging logs * Refactor and decouple UI models from `MainViewModel` * Refactor library state management and projection logic * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Introduce ReaderEngine and enhance EPUB reader features in windows app * Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support. * Implement PDF rendering and text extraction for desktop using Pdfium * Add `NonReaderScreens.kt` and UI dependencies * Refactor and centralize library state management and models to improve cross-platform consistency * Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing * Implement PDF annotation system and enhanced zoom controls for the desktop viewer * Implement WebView-based EPUB rendering for desktop using CEF and embedded resources * Optimize UI state projection, navigation state handling, and main screen pager performance * Implement Bring Your Own Key (BYOK) support for AI features in OSS version * Support Gemini-based Cloud TTS with BYOK support for OSS builds * Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling. * crash fixes * Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6 * Implement dynamic bottom padding for the page info bar to account for device rounded corners * Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar` * Optimize PDF tiling performance and refine pan-and-fling gesture handling * Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers * Updated UI for customize toolbar * Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls * restructure PDF viewer UI component hierarchy to fix verifier crash * Implement separate text dimming factors for light and dark themes * Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers * Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning * Centralize file type resolution logic and implement HTML sanitization during import * Introduce vertical margin customization and configurable progress bar positioning * texture support in epub reader * Enhance TTS session management, progress tracking, and diagnostic logging * Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic. * Refine TTS page mapping for PDF and overhaul TTS control UI * Implement natural session completion logic in `TtsPlaybackManager` for cloud tts * Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen` * Refine TTS playback continuity across PDF pages and improve state management for session transitions * Implement global texture transparency and enhance textured theme support across PDF and EPUB readers. * Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode * Add Support Project screen * Optimize library performance via projection caching, batch database updates, and scoped folder synchronization. * Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic * Bump version to 1.0.47 (51)
This commit is contained in:
parent
f42de6b462
commit
d7a9cae9e1
126 changed files with 15287 additions and 3154 deletions
|
|
@ -34,6 +34,7 @@ import java.io.File
|
|||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
|
@ -247,11 +248,16 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
|
||||
try {
|
||||
withTimeout(startTimeout) {
|
||||
startSignal.await()
|
||||
select {
|
||||
startSignal.onAwait { }
|
||||
resultDeferred.onAwait { }
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
Timber.w("BaseTts: ZOMBIE DETECTED. onStart not received within ${startTimeout}ms.")
|
||||
throw ZombieEngineException()
|
||||
Timber.w(
|
||||
"BaseTts: onStart not received within ${startTimeout}ms for $utteranceId. " +
|
||||
"Continuing to wait for onDone because some engines omit or delay onStart for file synthesis."
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -296,5 +302,4 @@ class BaseTtsSynthesizer(private val context: Context) {
|
|||
Timber.d("TextToSpeech engine shut down.")
|
||||
}
|
||||
|
||||
private class ZombieEngineException : Exception("Engine failed to start")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import androidx.media3.session.SessionToken
|
|||
import com.aryan.reader.BuildConfig
|
||||
import com.aryan.reader.epubreader.loadTtsPitch
|
||||
import com.aryan.reader.epubreader.loadTtsSpeechRate
|
||||
import com.aryan.reader.isByokCloudTtsAvailable
|
||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
|
|
@ -72,7 +73,7 @@ fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode {
|
|||
val savedModeName = prefs.getString("tts_mode", TtsPlaybackManager.TtsMode.BASE.name)
|
||||
?: TtsPlaybackManager.TtsMode.BASE.name
|
||||
|
||||
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank()
|
||||
val isCloudAllowed = BuildConfig.TTS_WORKER_URL.isNotBlank() || isByokCloudTtsAvailable(context)
|
||||
|
||||
return if (isCloudAllowed) {
|
||||
try {
|
||||
|
|
@ -105,8 +106,14 @@ class TtsController(context: Context) : Player.Listener {
|
|||
}
|
||||
|
||||
fun connect() {
|
||||
if (mediaController != null || controllerFuture != null) return
|
||||
if (mediaController != null || controllerFuture != null) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"TtsController.connect skipped. hasController=${mediaController != null}, hasFuture=${controllerFuture != null}"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("TtsController.connect building MediaController.")
|
||||
val sessionToken = SessionToken(context, ComponentName(context, TtsService::class.java))
|
||||
val future = MediaController.Builder(context, sessionToken).buildAsync()
|
||||
controllerFuture = future
|
||||
|
|
@ -129,10 +136,14 @@ class TtsController(context: Context) : Player.Listener {
|
|||
|
||||
mediaController?.addListener(this)
|
||||
Timber.d("MediaController connected.")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"MediaController connected. playbackState=${controller.playbackState}, isPlaying=${controller.isPlaying}, mediaItems=${controller.mediaItemCount}, customLayout=${controller.customLayout.size}"
|
||||
)
|
||||
updateStateFromController()
|
||||
startPolling()
|
||||
} catch (e: Exception) {
|
||||
Timber.w("Failed to connect MediaController: ${e.message}")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(e, "MediaController connection failed.")
|
||||
if (controllerFuture == future) {
|
||||
controllerFuture = null
|
||||
}
|
||||
|
|
@ -158,15 +169,21 @@ class TtsController(context: Context) : Player.Listener {
|
|||
chapterTitle: String?,
|
||||
coverImageUri: String?,
|
||||
chapterIndex: Int? = null,
|
||||
totalChapters: Int? = null,
|
||||
continueSession: Boolean = false,
|
||||
ttsMode: TtsPlaybackManager.TtsMode,
|
||||
playbackSource: String = "READER",
|
||||
authToken: String? = null
|
||||
) {
|
||||
if (chunks.isEmpty()) {
|
||||
Timber.w("TtsController: start called with empty chunks!")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsController.start aborted because chunks is empty.")
|
||||
return
|
||||
}
|
||||
Timber.d("UI sending START command with mode: $ttsMode")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"TtsController.start. hasController=${mediaController != null}, chunks=${chunks.size}, continueSession=$continueSession, source=$playbackSource, mode=$ttsMode, book='${bookTitle.take(60)}', chapter='${chapterTitle.orEmpty().take(60)}', chapterIndex=$chapterIndex, totalChapters=$totalChapters"
|
||||
)
|
||||
|
||||
val textList = ArrayList(chunks.map { it.text })
|
||||
val cfiList = ArrayList(chunks.map { it.sourceCfi })
|
||||
|
|
@ -181,6 +198,8 @@ class TtsController(context: Context) : Player.Listener {
|
|||
putString(KEY_CHAPTER_TITLE, chapterTitle)
|
||||
putString(KEY_COVER_IMAGE_URI, coverImageUri)
|
||||
chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
|
||||
totalChapters?.let { putInt(KEY_TOTAL_CHAPTERS, it) }
|
||||
putBoolean(KEY_CONTINUE_SESSION, continueSession)
|
||||
putString(KEY_TTS_MODE, ttsMode.name)
|
||||
putString(KEY_PLAYBACK_SOURCE, playbackSource)
|
||||
putString(KEY_AUTH_TOKEN, authToken)
|
||||
|
|
@ -188,7 +207,15 @@ class TtsController(context: Context) : Player.Listener {
|
|||
putFloat("playback_pitch", loadTtsPitch(context))
|
||||
}
|
||||
Timber.tag("TTS_CLOUD_DIAG").d("TtsController sending START. Mode: $ttsMode, Chunks: ${chunks.size}, Token present: ${!authToken.isNullOrBlank()}")
|
||||
mediaController?.sendCustomCommand(START_TTS_COMMAND, args)
|
||||
val controller = mediaController
|
||||
if (controller == null) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e("Cannot send START command because MediaController is null.")
|
||||
} else {
|
||||
val result = controller.sendCustomCommand(START_TTS_COMMAND, args)
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"START command sent. playbackState=${controller.playbackState}, isPlaying=${controller.isPlaying}, mediaItems=${controller.mediaItemCount}, resultDone=${result.isDone}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
|
|
@ -240,6 +267,9 @@ class TtsController(context: Context) : Player.Listener {
|
|||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"Controller onEvents. playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}, events=$events"
|
||||
)
|
||||
updateStateFromController()
|
||||
}
|
||||
|
||||
|
|
@ -247,7 +277,9 @@ class TtsController(context: Context) : Player.Listener {
|
|||
mediaController?.let { controller ->
|
||||
val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY
|
||||
val currentMediaItem = controller.currentMediaItem
|
||||
val currentTextFromMediaItem = currentMediaItem?.mediaMetadata?.subtitle?.toString()
|
||||
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
|
||||
val currentTextFromMediaItem = mediaItemExtras?.getString("ttsText")
|
||||
?: currentMediaItem?.mediaMetadata?.subtitle?.toString()
|
||||
val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING
|
||||
val serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId)
|
||||
val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false)
|
||||
|
|
@ -255,9 +287,13 @@ class TtsController(context: Context) : Player.Listener {
|
|||
val sessionFinished = customState.getBoolean("sessionFinished", false)
|
||||
val playbackSource = customState.getString("playbackSource")
|
||||
val serviceBookTitle = customState.getString("bookTitle")
|
||||
val serviceChapterTitle = customState.getString("chapterTitle")
|
||||
val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 }
|
||||
val serviceTotalChapters = customState.getInt("totalChapters", -1).takeIf { it > 0 }
|
||||
val serviceCurrentChunkIndex = customState.getInt("currentChunkIndex", -1)
|
||||
val serviceTotalChunks = customState.getInt("totalChunks", 0)
|
||||
val serviceBookProgressPercent = customState.getInt("bookProgressPercent", -1).takeIf { it >= 0 }
|
||||
|
||||
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
|
||||
val sourceCfi = mediaItemExtras?.getString("sourceCfi")
|
||||
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
|
||||
val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
|
||||
|
|
@ -279,11 +315,24 @@ class TtsController(context: Context) : Player.Listener {
|
|||
} else {
|
||||
if (isLoading) currentState.bookTitle else serviceBookTitle
|
||||
},
|
||||
chapterTitle = if (isPlaybackActive || isLoading) {
|
||||
serviceChapterTitle ?: currentState.chapterTitle
|
||||
} else {
|
||||
serviceChapterTitle
|
||||
},
|
||||
chapterIndex = if (isPlaybackActive || isLoading) {
|
||||
serviceChapterIndex ?: currentState.chapterIndex
|
||||
} else {
|
||||
serviceChapterIndex
|
||||
},
|
||||
totalChapters = if (isPlaybackActive || isLoading) {
|
||||
serviceTotalChapters ?: currentState.totalChapters
|
||||
} else {
|
||||
serviceTotalChapters
|
||||
},
|
||||
currentChunkIndex = serviceCurrentChunkIndex,
|
||||
totalChunks = serviceTotalChunks,
|
||||
bookProgressPercent = serviceBookProgressPercent,
|
||||
speakerId = serviceSpeaker,
|
||||
sourceCfi = if (isPlaybackActive) {
|
||||
sourceCfi
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ 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("com.aryan.reader.tts.START", Bundle.EMPTY)
|
||||
val STOP_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.STOP", Bundle.EMPTY)
|
||||
|
|
@ -57,6 +58,7 @@ private val STATE_UPDATE_COMMAND = SessionCommand("com.aryan.reader.tts.STATE_UP
|
|||
val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY)
|
||||
val SLICE_CURRENT_AND_RELOAD_COMMAND = SessionCommand("com.aryan.reader.tts.SLICE_AND_RELOAD", Bundle.EMPTY)
|
||||
val SET_PLAYBACK_PARAMS_COMMAND = SessionCommand("com.aryan.reader.tts.SET_PLAYBACK_PARAMS", Bundle.EMPTY)
|
||||
const val TTS_NOTIFICATION_DIAG_TAG = "TTS_NOTIFICATION_DIAG"
|
||||
|
||||
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
|
||||
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
|
||||
|
|
@ -71,6 +73,8 @@ 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"
|
||||
|
||||
private const val PREFETCH_LOOKAHEAD = 3
|
||||
|
||||
|
|
@ -102,7 +106,12 @@ class TtsPlaybackManager(
|
|||
val currentText: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val bookTitle: String? = null,
|
||||
val chapterTitle: String? = null,
|
||||
val chapterIndex: Int? = null,
|
||||
val totalChapters: 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,
|
||||
|
|
@ -124,6 +133,8 @@ class TtsPlaybackManager(
|
|||
private var chapterTitle: String? = null
|
||||
private var coverImageUri: String? = null
|
||||
private var currentTtsMode = TtsMode.CLOUD
|
||||
private var chapterIndex: Int? = null
|
||||
private var totalChapters: Int? = null
|
||||
|
||||
init {
|
||||
player.addListener(this)
|
||||
|
|
@ -146,6 +157,9 @@ class TtsPlaybackManager(
|
|||
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)
|
||||
|
|
@ -186,6 +200,9 @@ class TtsPlaybackManager(
|
|||
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 speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
|
||||
|
|
@ -193,6 +210,7 @@ class TtsPlaybackManager(
|
|||
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 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 }
|
||||
|
|
@ -208,10 +226,11 @@ class TtsPlaybackManager(
|
|||
|
||||
val authToken = args.getString(KEY_AUTH_TOKEN)
|
||||
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
|
||||
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, ttsMode, playbackSource, args)
|
||||
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, totalChapters, 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 -> {
|
||||
|
|
@ -342,17 +361,20 @@ class TtsPlaybackManager(
|
|||
chapterTitle: String?,
|
||||
coverImageUri: String?,
|
||||
chapterIndex: Int?,
|
||||
totalChapters: Int?,
|
||||
ttsMode: TtsMode,
|
||||
playbackSource: String?,
|
||||
args: Bundle // Added this parameter
|
||||
) {
|
||||
if (chunks.isEmpty()) {
|
||||
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
|
||||
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)
|
||||
|
||||
|
|
@ -365,27 +387,54 @@ class TtsPlaybackManager(
|
|||
}
|
||||
|
||||
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}, 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)
|
||||
}
|
||||
|
||||
handleStopTts(clearState = false)
|
||||
textChunks = chunks
|
||||
currentSpeakerId = speakerId
|
||||
currentTtsMode = ttsMode
|
||||
this.bookTitle = bookTitle
|
||||
this.chapterTitle = chapterTitle
|
||||
this.coverImageUri = coverImageUri
|
||||
this.chapterIndex = chapterIndex
|
||||
this.totalChapters = totalChapters
|
||||
|
||||
onResetContext()
|
||||
loadedChunks.clear()
|
||||
lastPrefetchIndex = -1
|
||||
|
||||
_ttsState.value = TtsState(
|
||||
isLoading = true,
|
||||
bookTitle = bookTitle,
|
||||
chapterTitle = chapterTitle,
|
||||
chapterIndex = chapterIndex,
|
||||
totalChapters = totalChapters,
|
||||
currentChunkIndex = -1,
|
||||
totalChunks = chunks.size,
|
||||
bookProgressPercent = calculateBookProgressPercent(-1),
|
||||
speakerId = speakerId,
|
||||
playbackSource = playbackSource,
|
||||
ttsMode = ttsMode.name
|
||||
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
|
||||
preparationJob = scope.launch {
|
||||
|
|
@ -411,6 +460,73 @@ class TtsPlaybackManager(
|
|||
Timber.d("Speaker changed to $newSpeakerId (pending next start)")
|
||||
}
|
||||
|
||||
private fun currentChunkIndexFromPlayer(): Int {
|
||||
return player.currentMediaItem?.mediaId?.toIntOrNull()
|
||||
?: player.currentMediaItemIndex
|
||||
}
|
||||
|
||||
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) {
|
||||
val firstChunk = textChunks.getOrNull(startAtIndex)
|
||||
if (firstChunk == null) {
|
||||
|
|
@ -420,6 +536,9 @@ class TtsPlaybackManager(
|
|||
|
||||
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"
|
||||
)
|
||||
|
||||
val ttsAudioData = generateAudioChunk(bookTitle ?: "Unknown Book", chapterTitle, startAtIndex, textChunks.size, firstChunk.text, currentSpeakerId, currentTtsMode, currentAuthToken)
|
||||
Timber.tag("TTS_CLOUD_DIAG").i("generateAudioChunk returned in ${System.currentTimeMillis() - chunkStartTime}ms")
|
||||
|
|
@ -464,17 +583,30 @@ class TtsPlaybackManager(
|
|||
}
|
||||
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}"
|
||||
)
|
||||
_ttsState.value = _ttsState.value.copy(
|
||||
isLoading = false,
|
||||
isPlaying = playWhenReady,
|
||||
currentText = serverText,
|
||||
chapterTitle = chapterTitle,
|
||||
chapterIndex = chapterIndex,
|
||||
totalChapters = totalChapters,
|
||||
currentChunkIndex = startAtIndex,
|
||||
totalChunks = textChunks.size,
|
||||
bookProgressPercent = calculateBookProgressPercent(startAtIndex),
|
||||
sessionFinished = false,
|
||||
sourceCfi = updatedChunk.sourceCfi,
|
||||
startOffsetInSource = updatedChunk.startOffsetInSource
|
||||
)
|
||||
}
|
||||
prefetchNextChunkAudio(startAtIndex)
|
||||
} else {
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.")
|
||||
_ttsState.value = _ttsState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = ttsAudioData.error ?: "Failed to load audio."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -509,6 +641,9 @@ class TtsPlaybackManager(
|
|||
|
||||
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"
|
||||
)
|
||||
onResetContext()
|
||||
preparationJob?.cancel()
|
||||
wordTrackingJob?.cancel()
|
||||
|
|
@ -527,6 +662,11 @@ class TtsPlaybackManager(
|
|||
player.stop()
|
||||
player.clearMediaItems()
|
||||
textChunks = emptyList()
|
||||
bookTitle = null
|
||||
chapterTitle = null
|
||||
coverImageUri = null
|
||||
chapterIndex = null
|
||||
totalChapters = null
|
||||
lastPrefetchIndex = -1
|
||||
prefetchLoopJob?.cancel()
|
||||
prefetchingJobs.values.forEach { it.cancel() }
|
||||
|
|
@ -541,17 +681,27 @@ class TtsPlaybackManager(
|
|||
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}"
|
||||
)
|
||||
if (newPlaylistIndex == C.INDEX_UNSET) return
|
||||
|
||||
val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
|
||||
|
||||
val newText = mediaItem.mediaMetadata.subtitle?.toString()
|
||||
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
|
||||
)
|
||||
|
|
@ -582,6 +732,9 @@ class TtsPlaybackManager(
|
|||
}
|
||||
|
||||
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}"
|
||||
)
|
||||
var nextState = _ttsState.value.copy(isPlaying = isPlaying)
|
||||
|
||||
if (isPlaying) {
|
||||
|
|
@ -599,14 +752,22 @@ class TtsPlaybackManager(
|
|||
currentWordStartOffset = -1
|
||||
)
|
||||
|
||||
val currentChunkIndex = player.currentMediaItemIndex
|
||||
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()}"
|
||||
)
|
||||
if (isLastChunkInSession || textChunks.isEmpty()) {
|
||||
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").i("Setting sessionFinished = true")
|
||||
nextState = nextState.copy(sessionFinished = true)
|
||||
nextState = nextState.copy(
|
||||
currentChunkIndex = currentChunkIndex,
|
||||
totalChunks = textChunks.size,
|
||||
bookProgressPercent = calculateBookProgressPercent(currentChunkIndex),
|
||||
sessionFinished = true
|
||||
)
|
||||
} else {
|
||||
val nextIdx = currentChunkIndex + 1
|
||||
val isPrefetching = prefetchingJobs.containsKey(nextIdx)
|
||||
|
|
@ -625,6 +786,7 @@ class TtsPlaybackManager(
|
|||
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}")
|
||||
|
|
@ -634,6 +796,7 @@ class TtsPlaybackManager(
|
|||
|
||||
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 = "Playback error: ${error.message}")
|
||||
handleStopTts(userInitiated = true)
|
||||
}
|
||||
|
|
@ -719,10 +882,13 @@ class TtsPlaybackManager(
|
|||
player.addMediaItem(insertPosition, nextMediaItem)
|
||||
}
|
||||
|
||||
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && targetIndex == player.currentMediaItemIndex + 1) {
|
||||
val currentChunkIndex = currentChunkIndexFromPlayer()
|
||||
val isImmediateNextChunk = targetIndex == currentChunkIndex + 1
|
||||
|
||||
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && isImmediateNextChunk) {
|
||||
player.seekToNextMediaItem()
|
||||
player.play()
|
||||
} else if (wasLoading && targetIndex == player.currentMediaItemIndex + 1) {
|
||||
} else if (wasLoading && isImmediateNextChunk) {
|
||||
_ttsState.value = _ttsState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
|
|
@ -768,7 +934,10 @@ class TtsPlaybackManager(
|
|||
if (player.hasNextMediaItem()) {
|
||||
player.seekToNextMediaItem()
|
||||
} else {
|
||||
player.stop()
|
||||
val finishedChunkIndex = currentMediaItem.mediaId.toIntOrNull()
|
||||
?: currentChunkIndexFromPlayer()
|
||||
markSessionFinishedNaturally(finishedChunkIndex)
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -811,7 +980,37 @@ class TtsPlaybackManager(
|
|||
}
|
||||
|
||||
private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem {
|
||||
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)
|
||||
if (chunk.timedWords.isNotEmpty()) {
|
||||
|
|
@ -823,9 +1022,11 @@ class TtsPlaybackManager(
|
|||
}
|
||||
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setArtist(bookTitle)
|
||||
.setTitle(chapterTitle)
|
||||
.setSubtitle(text)
|
||||
.setTitle(bookTitle ?: chapterLabel)
|
||||
.setDisplayTitle(bookTitle ?: chapterLabel)
|
||||
.setArtist(chapterLabel)
|
||||
.setSubtitle(chunkPreview)
|
||||
.setDescription(chunkPreview)
|
||||
.setArtworkUri(coverImageUri?.toUri())
|
||||
.setTrackNumber(index + 1)
|
||||
.setTotalTrackCount(textChunks.size)
|
||||
|
|
@ -865,7 +1066,12 @@ class TtsPlaybackManager(
|
|||
putBoolean("isLoading", state.isLoading)
|
||||
putString("errorMessage", state.errorMessage)
|
||||
putString("bookTitle", state.bookTitle)
|
||||
putString("chapterTitle", state.chapterTitle)
|
||||
putInt("chapterIndex", state.chapterIndex ?: -1)
|
||||
putInt("totalChapters", state.totalChapters ?: -1)
|
||||
putInt("currentChunkIndex", state.currentChunkIndex)
|
||||
putInt("totalChunks", state.totalChunks)
|
||||
putInt("bookProgressPercent", state.bookProgressPercent ?: -1)
|
||||
putString("speakerId", state.speakerId)
|
||||
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
|
||||
putString("currentWordSourceCfi", state.currentWordSourceCfi)
|
||||
|
|
@ -905,5 +1111,8 @@ class TtsPlaybackManager(
|
|||
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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ import androidx.media3.common.util.UnstableApi
|
|||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL
|
||||
import com.aryan.reader.isByokCloudTtsAvailable
|
||||
import com.aryan.reader.loadAiByokSettings
|
||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -236,16 +239,31 @@ class TtsService : MediaSessionService() {
|
|||
private lateinit var cacheManager: TtsCacheManager
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
val playerState = if (::player.isInitialized) {
|
||||
"playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}"
|
||||
} else {
|
||||
"player=uninitialized"
|
||||
}
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"onUpdateNotification called. startInForegroundRequired=$startInForegroundRequired, hasPostNotifications=$hasNotificationPermission, $playerState"
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
|
||||
if (startInForegroundRequired) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing while foreground is required. Calling stopSelf().")
|
||||
stopSelf()
|
||||
} else {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing. Skipping notification update.")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.")
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
|
||||
|
|
@ -279,7 +297,12 @@ class TtsService : MediaSessionService() {
|
|||
data class Error(val message: String) : GeminiWsEvent()
|
||||
}
|
||||
|
||||
suspend fun ensureConnected(serverUrl: String, speaker: String, authToken: String?) = connectionMutex.withLock {
|
||||
suspend fun ensureConnected(
|
||||
serverUrl: String,
|
||||
speaker: String,
|
||||
authToken: String?,
|
||||
directGeminiApiKey: String? = null
|
||||
) = connectionMutex.withLock {
|
||||
if (webSocket != null) {
|
||||
if (connectedSpeaker == speaker) {
|
||||
val isSetup = try { setupDeferred.await() } catch(_: Exception) { false }
|
||||
|
|
@ -290,11 +313,15 @@ class TtsService : MediaSessionService() {
|
|||
webSocket = null
|
||||
}
|
||||
|
||||
val sanitizedUrl = serverUrl.removeSuffix("/")
|
||||
val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
|
||||
val url = "$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}"
|
||||
val url = if (!directGeminiApiKey.isNullOrBlank()) {
|
||||
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent?key=$directGeminiApiKey"
|
||||
} else {
|
||||
val sanitizedUrl = serverUrl.removeSuffix("/")
|
||||
val wsUrlStr = sanitizedUrl.replace("https://", "wss://").replace("http://", "ws://")
|
||||
"$wsUrlStr/live?speaker=$speaker&token=${authToken ?: ""}"
|
||||
}
|
||||
|
||||
Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: $url")
|
||||
Timber.tag("TTS_CLOUD_DIAG").d("Connecting to WS: ${if (!directGeminiApiKey.isNullOrBlank()) "Gemini BYOK" else url}")
|
||||
val request = Request.Builder().url(url).build()
|
||||
val connectedDeferred = CompletableDeferred<Boolean>()
|
||||
|
||||
|
|
@ -316,7 +343,7 @@ class TtsService : MediaSessionService() {
|
|||
|
||||
val setupMsg = JSONObject().apply {
|
||||
put("setup", JSONObject().apply {
|
||||
put("model", "models/gemini-3.1-flash-live-preview")
|
||||
put("model", "models/$GEMINI_CLOUD_TTS_MODEL")
|
||||
put("systemInstruction", JSONObject().apply {
|
||||
put("parts", org.json.JSONArray().apply {
|
||||
put(JSONObject().apply {
|
||||
|
|
@ -552,8 +579,17 @@ class TtsService : MediaSessionService() {
|
|||
TtsAudioData(audioFile = cachedFile, serverText = text, wordTimings = emptyList(), error = null, streamUri = null)
|
||||
} else {
|
||||
try {
|
||||
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken)
|
||||
liveClient.generateChunk(text, cachedFile)
|
||||
val directGeminiApiKey = if (isByokCloudTtsAvailable(this@TtsService)) {
|
||||
loadAiByokSettings(this@TtsService).geminiKey
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (directGeminiApiKey.isNullOrBlank() && googleCloudWorkerTtsUrl.isBlank()) {
|
||||
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = "Cloud TTS is not configured.")
|
||||
} else {
|
||||
liveClient.ensureConnected(googleCloudWorkerTtsUrl, speaker, authToken, directGeminiApiKey)
|
||||
liveClient.generateChunk(text, cachedFile)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("TTS_CLOUD_DIAG").e(e, "Cloud TTS generation failed")
|
||||
TtsAudioData(audioFile = null, serverText = null, wordTimings = null, error = e.message ?: "Failed to connect to TTS service")
|
||||
|
|
@ -567,6 +603,11 @@ class TtsService : MediaSessionService() {
|
|||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Timber.d("TtsService created.")
|
||||
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"TtsService onCreate. sdk=${Build.VERSION.SDK_INT}, hasPostNotifications=$hasNotificationPermission"
|
||||
)
|
||||
|
||||
cacheManager = TtsCacheManager(this)
|
||||
|
||||
|
|
@ -622,6 +663,7 @@ class TtsService : MediaSessionService() {
|
|||
.setHandleAudioBecomingNoisy(true)
|
||||
.setMediaSourceFactory(androidx.media3.exoplayer.source.DefaultMediaSourceFactory(this).setDataSourceFactory(dataSourceFactory))
|
||||
.build()
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("ExoPlayer created for TTS service.")
|
||||
|
||||
playbackManager = TtsPlaybackManager(
|
||||
player = player,
|
||||
|
|
@ -634,21 +676,30 @@ class TtsService : MediaSessionService() {
|
|||
.build()
|
||||
|
||||
mediaSession?.let { playbackManager.setMediaSession(it) }
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("MediaSession created and attached to playback manager. sessionAvailable=${mediaSession != null}")
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}"
|
||||
)
|
||||
if (!player.playWhenReady) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().")
|
||||
stopSelf()
|
||||
}
|
||||
Timber.d("onTaskRemoved called, stopping service.")
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"onGetSession. package=${controllerInfo.packageName}, sessionAvailable=${mediaSession != null}"
|
||||
)
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Timber.d("TtsService is being destroyed.")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.")
|
||||
baseTtsSynthesizer.shutdown()
|
||||
playbackManager.release()
|
||||
mediaSession?.run {
|
||||
|
|
@ -658,4 +709,4 @@ class TtsService : MediaSessionService() {
|
|||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -394,4 +394,4 @@ fun createWavHeaderUnknownLength(sampleRate: Int): ByteArray {
|
|||
header.putInt(0x7FFFFFFF - 36)
|
||||
|
||||
return header.array()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue