fix(audio, ui): correct MP3 sentence tracking, prevent auto-gen, and enable text selection

Implement periodic position updates in Mp3PlaybackService for real-time MP3 highlighting. Add flag in MainViewModel to prevent automatic MP3 generation for non-document inputs (e.g., pasted text). Enable text selection in the main display area using SelectionContainer. Adjust MP3 control button size in MainScreen for UI consistency.
This commit is contained in:
dave 2025-04-03 21:42:34 +03:00
parent 57ec7f1b74
commit d98fc5ec50
2 changed files with 344 additions and 118 deletions
app/src/main/java/com/voca/app

@ -94,6 +94,7 @@ import androidx.compose.material3.SnackbarDuration
import kotlinx.coroutines.Job
import androidx.compose.material.icons.filled.Replay
import kotlin.math.roundToInt
import kotlin.math.roundToLong
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.ui.text.SpanStyle
@ -102,6 +103,8 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
import androidx.compose.material.icons.automirrored.filled.NavigateNext
import androidx.compose.ui.platform.LocalLifecycleOwner
import com.voca.app.domain.model.SentenceTiming
import com.voca.app.domain.model.TextRange
/**
* MainScreen is the primary UI component for the Voca app
@ -143,6 +146,12 @@ fun MainScreen(
// Observe Document Processing status to enable MP3 generation
val processingStatus by viewModel.processingStatus.collectAsStateWithLifecycle()
// --- Observe states relevant for MP3 playback and highlighting ---
val currentMp3Uri by viewModel.currentMp3Uri.collectAsStateWithLifecycle()
val fullDocumentText by viewModel.fullDocumentText.collectAsStateWithLifecycle()
val sentenceTimings by viewModel.sentenceTimings.collectAsStateWithLifecycle()
val playbackPositionMs by viewModel.playbackPositionMs.collectAsStateWithLifecycle()
// Context for showing Toast messages
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
@ -305,10 +314,17 @@ fun MainScreen(
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Text Display Area
TextDisplayArea(
text = currentPageText,
ttsState = ttsState,
// Determine which text source and highlighting mechanism to use
val displayText = if (currentMp3Uri != null) fullDocumentText else currentPageText
val displayTimings = if (currentMp3Uri != null) sentenceTimings else emptyList()
val displayPositionMs = if (currentMp3Uri != null) playbackPositionMs else -1L
FullTextDisplayArea(
text = displayText,
sentenceTimings = displayTimings,
playbackPositionMs = displayPositionMs,
ttsSentenceIndex = if (currentMp3Uri == null) ttsState.currentSentenceIndex else -1,
ttsSentences = if (currentMp3Uri == null) ttsState.sentences else emptyList(),
modifier = Modifier
.weight(1f) // Takes up available space
.fillMaxWidth()
@ -317,18 +333,10 @@ fun MainScreen(
Spacer(modifier = Modifier.height(16.dp))
// Pagination Controls (conditionally shown)
if (isPaginationEnabled) {
PaginationControls(
onPrevious = { (viewModel::onAction)(MainAction.PreviousPage) },
onNext = { (viewModel::onAction)(MainAction.NextPage) },
isPreviousEnabled = isPreviousPageAvailable,
isNextEnabled = isNextPageAvailable
)
Spacer(modifier = Modifier.height(16.dp))
}
// Pagination controls removed as they are redundant with MP3 seek bar/timing
// TTS Playback Controls
if (currentMp3Uri == null) {
TtsControls(
ttsState = ttsState,
onPlayPauseToggle = {
@ -342,6 +350,7 @@ fun MainScreen(
},
onStop = { (viewModel::onAction)(MainAction.StopSpeaking) }
)
}
// Optional: Show subtle indicator if background audio is playing
if (playbackState.isPlaying) {
@ -349,10 +358,16 @@ fun MainScreen(
Text("MP3 Playing...", style = MaterialTheme.typography.labelSmall) // Keep text, remove indicator
}
// --- Logging MP3 state before rendering controls ---
Timber.d("Rendering Controls: currentMp3Uri=$currentMp3Uri, playbackState.currentUri=${playbackState.currentUri}, isPlaying=${playbackState.isPlaying}, isPaused=${playbackState.isPaused}, isLoading=${playbackState.isLoading}, genState=$mp3GenerationState")
// ---
// --- MP3 Player Controls ---
Mp3PlayerControls(
playbackState = playbackState,
mp3GenerationState = mp3GenerationState,
currentMp3UriFromViewModel = currentMp3Uri,
onPlay = { (viewModel::onAction)(MainAction.PlayMp3) },
onPause = { (viewModel::onAction)(MainAction.PauseMp3) },
onResume = { (viewModel::onAction)(MainAction.ResumeMp3) },
onStop = { (viewModel::onAction)(MainAction.StopMp3) },
@ -398,38 +413,6 @@ fun TtsControls(
}
}
// Simple composable for Pagination control buttons
@Composable
fun PaginationControls(
onPrevious: () -> Unit,
onNext: () -> Unit,
isPreviousEnabled: Boolean,
isNextEnabled: Boolean
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = onPrevious, enabled = isPreviousEnabled) {
Icon(
imageVector = Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = "Previous Sentence"
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text("Previous")
}
Button(onClick = onNext, enabled = isNextEnabled) {
Text("Next")
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Icon(
imageVector = Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = "Next Sentence"
)
}
}
}
// --- Preview Data Structure ---
// Simple data class to hold state for preview purposes, avoiding complex ViewModel mocking
data class PreviewState(
@ -501,12 +484,7 @@ fun MainScreenPreviewDark() {
Spacer(modifier = Modifier.height(16.dp))
if(isPagEnabled) {
PaginationControls(
onPrevious = { state.onAction(MainAction.PreviousPage) }, // Use action lambda
onNext = { state.onAction(MainAction.NextPage) },
isPreviousEnabled = isPrevEnabled,
isNextEnabled = isNextEnabled
)
// PaginationControls removed, no replacement needed in preview
Spacer(modifier = Modifier.height(16.dp))
}
@ -549,18 +527,21 @@ fun MainScreenPreviewDark() {
fun Mp3PlayerControls(
playbackState: PlaybackState,
mp3GenerationState: Mp3GenerationProgress?,
currentMp3UriFromViewModel: Uri?,
onPlay: () -> Unit,
onPause: () -> Unit,
onResume: () -> Unit,
onStop: () -> Unit,
onCancel: () -> Unit,
onSeek: (Int) -> Unit
onSeek: (Long) -> Unit
) {
// Only show the Card if there's something to display
val shouldShowControls = mp3GenerationState is Mp3GenerationProgress.InProgress ||
playbackState.currentUri != null ||
playbackState.isLoading ||
// Only show the Card if there's something to display (generation in progress, URI available, loading, or error)
val shouldShowControls = mp3GenerationState is Mp3GenerationProgress.InProgress ||
playbackState.currentUri != null ||
currentMp3UriFromViewModel != null ||
playbackState.isLoading ||
playbackState.error != null
if (shouldShowControls) {
Card(
modifier = Modifier.fillMaxWidth(),
@ -617,7 +598,7 @@ fun Mp3PlayerControls(
style = MaterialTheme.typography.bodyMedium
)
}
// Handle Loading State
// Handle Loading State (When Play is pressed but before media is ready)
else if (playbackState.isLoading) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
@ -625,26 +606,42 @@ fun Mp3PlayerControls(
Text("Loading MP3...", style = MaterialTheme.typography.bodyMedium)
}
}
// Handle Ready/Playing/Paused State (only if URI is present)
else if (playbackState.currentUri != null) {
// Handle Ready/Playing/Paused State (if generation is NOT in progress and a URI is available from PlaybackState OR ViewModel)
else if (playbackState.currentUri != null || currentMp3UriFromViewModel != null) {
// Determine the effective URI to use for display logic (prefer playback state if available)
val effectiveUri = playbackState.currentUri ?: currentMp3UriFromViewModel
// Play/Pause/Stop Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
// Play/Pause Button
IconButton(onClick = { if (playbackState.isPlaying) onPause() else onResume() }) {
// Play/Pause Button - Updated onClick logic
IconButton(onClick = {
when {
playbackState.isPlaying -> onPause()
playbackState.isPaused -> onResume()
else -> onPlay() // Call onPlay if neither playing nor paused
}
}) {
Icon(
imageVector = if (playbackState.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (playbackState.isPlaying) "Pause" else "Play/Resume",
contentDescription = when {
playbackState.isPlaying -> "Pause"
playbackState.isPaused -> "Resume"
else -> "Play"
},
modifier = Modifier.size(36.dp)
)
}
// Stop Button
IconButton(onClick = onStop) {
// Stop Button - Enabled if playing or paused, or if ready but not playing
IconButton(
onClick = onStop,
enabled = playbackState.isPlaying || playbackState.isPaused || (effectiveUri != null && !playbackState.isPlaying)
) {
Icon(
imageVector = Icons.Filled.Stop, // Changed from Replay for clarity
imageVector = Icons.Filled.Stop,
contentDescription = "Stop",
modifier = Modifier.size(36.dp)
)
@ -653,11 +650,13 @@ fun Mp3PlayerControls(
Spacer(modifier = Modifier.height(8.dp))
// Seek Bar and Time Display
// Seek Bar and Time Display - Logic depends on playbackState reflecting actual player status
if (playbackState.durationMs > 0) {
Slider(
value = playbackState.currentPositionMs.toFloat(),
onValueChange = { onSeek(it.roundToInt()) },
onValueChange = {
onSeek(it.roundToLong())
},
valueRange = 0f..playbackState.durationMs.toFloat(),
modifier = Modifier.fillMaxWidth()
)
@ -669,12 +668,17 @@ fun Mp3PlayerControls(
Text(formatDuration(playbackState.durationMs), style = MaterialTheme.typography.labelSmall)
}
} else {
// Show something if duration isn't available yet but playing
// Show something if duration isn't available yet but playing/paused
// Or if ready to play (effectiveUri != null)
if (playbackState.isPlaying || playbackState.isPaused) {
Text("Playing...", style = MaterialTheme.typography.labelSmall)
} else if (effectiveUri != null) {
Text("Ready (00:00 / --:--)", style = MaterialTheme.typography.labelSmall) // Indicate ready state
}
}
}
// Implicitly handles the case where generation is finished, no error, not loading,
// but no URI is available yet (e.g., initial state before loading/generation). Nothing is shown.
}
}
}
@ -684,61 +688,137 @@ fun Mp3PlayerControls(
/**
* Formats milliseconds duration into MM:SS format.
*/
private fun formatDuration(millis: Int): String {
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis.toLong())
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis.toLong()) % 60
private fun formatDuration(millis: Long): String {
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis)
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
return String.format("%02d:%02d", minutes, seconds)
}
/**
* New Composable for displaying the full text with sentence highlighting.
*/
@Composable
fun TextDisplayArea(
fun FullTextDisplayArea(
text: String,
ttsState: TTSState,
sentenceTimings: List<SentenceTiming>,
playbackPositionMs: Long,
ttsSentenceIndex: Int,
ttsSentences: List<TextRange>,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
val currentSentenceIndex = ttsState.currentSentenceIndex
val sentences = ttsState.sentences
// Determine the currently highlighted sentence index based on MP3 position or TTS state
val currentSentenceIndex by remember(sentenceTimings, playbackPositionMs, ttsSentenceIndex) {
derivedStateOf {
if (sentenceTimings.isNotEmpty() && playbackPositionMs >= 0) {
// Find the index of the sentence that contains the current playback position
sentenceTimings.indexOfFirst { timing ->
playbackPositionMs >= timing.startMs && playbackPositionMs < (timing.startMs + timing.durationMs)
}.takeIf { it != -1 } ?: -1 // Return -1 if not found
} else if (ttsSentenceIndex != -1) {
// Fallback to TTS highlighting if MP3 timings are not available/active
ttsSentenceIndex
} else {
-1 // No highlighting
}
}
}
// Auto-scroll to current sentence when it changes
LaunchedEffect(currentSentenceIndex) {
if (currentSentenceIndex >= 0 && currentSentenceIndex < sentences.size) {
val sentence = sentences[currentSentenceIndex]
// Calculate approximate scroll position based on sentence start position relative to total text
val scrollPosition = (sentence.start.toFloat() / text.length.toFloat() * scrollState.maxValue).toInt()
// Decide whether to use MP3 timings or TTS ranges for scrolling calculation
val targetStartChar: Int? = when {
currentSentenceIndex >= 0 && sentenceTimings.isNotEmpty() -> {
// Estimate start character based on timing start (less accurate than range)
// A better approach might be to store char ranges in SentenceTiming if possible
val timing = sentenceTimings[currentSentenceIndex]
// Rough estimate: Assume uniform character distribution
val totalDuration = sentenceTimings.lastOrNull()?.let { it.startMs + it.durationMs } ?: 1L
if (totalDuration > 0) (timing.startMs.toDouble() / totalDuration * text.length).toInt() else 0
}
currentSentenceIndex >= 0 && ttsSentences.isNotEmpty() && currentSentenceIndex < ttsSentences.size -> {
// Use precise TTS character range
ttsSentences[currentSentenceIndex].start
}
else -> null
}
targetStartChar?.let {
// Calculate scroll position based on character start position
val scrollPosition = (it.toFloat() / text.length.toFloat() * scrollState.maxValue).toInt()
Timber.d("Scrolling to index $currentSentenceIndex (char $it), position $scrollPosition / ${scrollState.maxValue}")
scrollState.animateScrollTo(scrollPosition)
}
}
val annotatedString = buildAnnotatedString {
if (sentences.isEmpty()) {
// If no sentences parsed, just display the regular text
append(text)
} else {
var currentIndex = 0
sentences.forEachIndexed { index, range ->
// Append text before the sentence if there's a gap
if (range.start > currentIndex) {
append(text.substring(currentIndex, range.start))
}
// Use MP3 timings if available, otherwise fall back to TTS ranges or plain text
when {
sentenceTimings.isNotEmpty() -> {
var lastIndex = 0
sentenceTimings.forEachIndexed { index, timing ->
// Find the actual text segment for this timing. Requires careful mapping.
// For now, we assume sentenceTimings[i].text contains the correct segment.
// A more robust approach might involve storing start/end indices in SentenceTiming.
val sentenceText = timing.text ?: "" // Use text from timing if available
// Apply highlighting if this is the current sentence
// VERY basic search for the text - THIS IS INEFFICIENT and potentially WRONG
// Ideally, SentenceTiming should include character indices.
val startIndex = text.indexOf(sentenceText, startIndex = lastIndex).takeIf { it != -1 } ?: lastIndex
val endIndex = startIndex + sentenceText.length
// Append text before the sentence
if (startIndex > lastIndex) {
append(text.substring(lastIndex, startIndex))
}
// Highlight the current sentence
if (index == currentSentenceIndex) {
withStyle(style = SpanStyle(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
background = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)) {
append(range.text)
append(sentenceText)
}
} else {
append(sentenceText)
}
lastIndex = endIndex
}
// Append remaining text
if (lastIndex < text.length) {
append(text.substring(lastIndex))
}
}
ttsSentences.isNotEmpty() -> {
// Fallback to TTS highlighting logic
var currentIndex = 0
ttsSentences.forEachIndexed { index, range ->
if (range.start > currentIndex) {
append(text.substring(currentIndex, range.start))
}
if (index == currentSentenceIndex) { // Use ttsSentenceIndex here via currentSentenceIndex
withStyle(style = SpanStyle(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
background = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)) {
append(range.text)
}
} else {
append(range.text)
}
currentIndex = range.end
}
// Append any remaining text after the last sentence
if (currentIndex < text.length) {
append(text.substring(currentIndex))
}
}
else -> {
// No timing/sentence info, just display plain text
append(text)
}
}
}

@ -46,6 +46,14 @@ import kotlinx.coroutines.flow.first
import com.voca.app.domain.model.FileSaveProgress
import kotlinx.coroutines.Job
import com.voca.app.domain.usecase.SaveMp3UseCase
import com.voca.app.domain.usecase.CheckForExistingMediaUseCase
import com.voca.app.domain.usecase.LoadAssociatedContentUseCase
import com.voca.app.domain.model.SentenceTimingInfo
import android.content.Context
import android.provider.OpenableColumns
import com.voca.app.domain.repository.IFileSaverRepository
import com.voca.app.domain.usecase.LoadSentenceTimingUseCase
import com.voca.app.domain.model.SentenceTiming
// --- Define UI Actions using a Sealed Interface (RULE-010) ---
sealed interface MainAction {
@ -65,7 +73,8 @@ sealed interface MainAction {
data object PauseMp3 : MainAction
data object ResumeMp3 : MainAction
data object StopMp3 : MainAction
data class SeekMp3(val positionMs: Int) : MainAction
// Change parameter type to Long
data class SeekMp3(val positionMs: Long) : MainAction
data object NextPage : MainAction
data object PreviousPage : MainAction
data object TriggerDocumentPicker : MainAction
@ -97,7 +106,11 @@ class MainViewModel(
private val stopMp3UseCase: StopMp3UseCase,
private val seekMp3UseCase: SeekMp3UseCase,
private val getPlaybackStateUseCase: GetPlaybackStateUseCase,
private val releaseMp3PlayerUseCase: ReleaseMp3PlayerUseCase
private val releaseMp3PlayerUseCase: ReleaseMp3PlayerUseCase,
private val checkForExistingMediaUseCase: CheckForExistingMediaUseCase,
private val loadAssociatedContentUseCase: LoadAssociatedContentUseCase,
private val fileSaverRepository: IFileSaverRepository,
private val loadSentenceTimingUseCase: LoadSentenceTimingUseCase
) : ViewModel() {
// Add a flag to track when the app is ready for TTS operations
@ -202,10 +215,25 @@ class MainViewModel(
val ttsStateFlow: StateFlow<TTSState> = ttsRepository.ttsStateFlow
// ---
// --- MP3 Playback State ---
val playbackState: StateFlow<PlaybackState> = getPlaybackStateUseCase() // Expose state flow from use case
// --- MP3 Playback State (using specific getters from UseCase) ---
val playbackState: StateFlow<PlaybackState> = getPlaybackStateUseCase.getPlaybackState() // Use specific getter
val playbackPositionMs: StateFlow<Long> = getPlaybackStateUseCase.getPlaybackPositionMs() // Use specific getter
// ---
// --- Sentence Timing and Highlighting State ---
private val _sentenceTimings = MutableStateFlow<List<SentenceTiming>>(emptyList())
val sentenceTimings: StateFlow<List<SentenceTiming>> = _sentenceTimings.asStateFlow()
private val _fullDocumentText = MutableStateFlow("")
val fullDocumentText: StateFlow<String> = _fullDocumentText.asStateFlow()
private val _highlightedSentenceId = MutableStateFlow<String?>(null)
val highlightedSentenceId: StateFlow<String?> = _highlightedSentenceId.asStateFlow()
private val _currentMp3Uri = MutableStateFlow<Uri?>(null)
val currentMp3Uri: StateFlow<Uri?> = _currentMp3Uri.asStateFlow()
// --- End Sentence Timing State ---
// State for MP3 generation progress
private val _mp3GenerationProgress = MutableStateFlow<Mp3GenerationProgress?>(null)
val mp3GenerationProgress: StateFlow<Mp3GenerationProgress?> = _mp3GenerationProgress.asStateFlow()
@ -284,16 +312,41 @@ class MainViewModel(
Timber.d("handleLoadDocumentAndInitiateAudioProcessing called for: $fileName")
viewModelScope.launch {
_isLoading.value = true
// Use EXTRACTING_TEXT instead of LOADING_DOCUMENT
_processingStatus.value = PdfProcessingStatus.EXTRACTING_TEXT
_mp3SaveProgress.value = null // Reset save state
// Reset potentially loaded state first
_currentMp3Uri.value = null
_sentenceTimings.value = emptyList<SentenceTiming>()
_fullDocumentText.value = ""
_highlightedSentenceId.value = null
_mp3SaveProgress.value = null
_mp3GenerationProgress.value = null
_processingStatus.value = PdfProcessingStatus.LOADING_DOCUMENT // New initial status
try {
Timber.d("Calling loadDocumentUseCase for $fileName")
loadDocumentUseCase(uri)
// The loadDocumentUseCase will update the repository state asynchronously.
// We rely on the existing collectors observing processingStatus or document state
// to trigger the subsequent actions (like MP3 generation).
Timber.d("loadDocumentUseCase call initiated for $fileName. Waiting for state updates.")
// 1. Check if pre-processed media exists
val existingMediaResult = checkForExistingMediaUseCase(uri)
if (existingMediaResult.mp3Exists && existingMediaResult.metadataExists) { // Check if files exist
Timber.i("Existing MP3 and metadata found for $fileName. Loading associated content.")
_processingStatus.value = PdfProcessingStatus.LOADING_ASSOCIATED_CONTENT // New status
val loadedContent = loadAssociatedContentUseCase(uri)
if (loadedContent != null) {
_currentMp3Uri.value = loadedContent.mp3Uri
_sentenceTimings.value = loadedContent.sentenceTimings // Should now be List<SentenceTiming>
_fullDocumentText.value = loadedContent.fullText
_processingStatus.value = PdfProcessingStatus.READY_TO_PLAY // Ready state
Timber.i("Successfully loaded existing content for $fileName. Ready to play.")
// Optionally auto-play?
// handlePlayMp3()
// *** IMPORTANT: Do nothing further, content is loaded ***
} else {
Timber.e("Failed to load associated content even though files exist for $fileName. Proceeding with generation.")
// Fall through to generation if loading fails unexpectedly
processAndGenerate(uri, fileName)
}
} else { // Files do NOT exist, proceed with generation
Timber.i("Existing media not found (MP3=${existingMediaResult.mp3Exists}, Meta=${existingMediaResult.metadataExists}) for $fileName. Proceeding with generation.")
// 2. If not, proceed with original extraction/generation
processAndGenerate(uri, fileName)
}
} catch (e: CancellationException) {
Timber.w("Document processing cancelled for $fileName")
@ -307,6 +360,23 @@ class MainViewModel(
}
}
private suspend fun processAndGenerate(uri: Uri, fileName: String) {
_processingStatus.value = PdfProcessingStatus.EXTRACTING_TEXT
try {
Timber.d("Calling loadDocumentUseCase for $fileName")
loadDocumentUseCase(uri)
// We rely on the existing collectors observing processingStatus or document state
// to trigger the subsequent actions (like MP3 generation).
Timber.d("loadDocumentUseCase call initiated for $fileName. Waiting for state updates.")
// Status will automatically transition via the init{} collector when loadDocumentUseCase updates repo to COMPLETED
} catch (e: CancellationException) {
throw e // Re-throw cancellation to be caught by outer handler
} catch (e: Exception) {
Timber.e(e, "Error in processAndGenerate during document load for $fileName")
_processingStatus.value = PdfProcessingStatus.ERROR
}
}
private fun handlePlayText() {
val currentTextValue = getCurrentDocumentStateUseCase.currentPageText.value
if (!currentTextValue.isNullOrBlank()) {
@ -420,10 +490,54 @@ class MainViewModel(
}
}
is Mp3GenerationProgress.Success -> {
Timber.i("MP3 Generation Success. URI: ${progress.mp3Uri}")
Timber.i("MP3 Generation Success. MP3 URI: ${progress.mp3Uri}, JSON URI: ${progress.jsonUri}")
_mp3GenerationProgress.value = progress // Update state
generationSuccessUri = progress.mp3Uri // Store URI for saving
_isLoading.value = false // Generation part is done
// --- Update the current MP3 URI state ---
_currentMp3Uri.value = progress.mp3Uri
// --- Load Timings and Full Text from generated JSON ---
progress.jsonUri?.let { jsonUri ->
viewModelScope.launch(Dispatchers.IO) { // Use IO Dispatcher for file reading
try {
Timber.d("Attempting to load timings from generated JSON: $jsonUri")
val timings = loadSentenceTimingUseCase(jsonUri)
if (timings.isNotEmpty()) {
// Reconstruct the full text from timings
val reconstructedText = timings.joinToString(separator = " ") { it.text ?: "" }.trim()
withContext(Dispatchers.Main) { // Switch back to Main thread to update StateFlows
_sentenceTimings.value = timings
_fullDocumentText.value = reconstructedText
Timber.i("Successfully loaded ${timings.size} timings and updated full text after generation.")
// Update overall status to reflect readiness for playback
_processingStatus.value = PdfProcessingStatus.READY_TO_PLAY
}
} else {
Timber.w("Loaded timing file after generation ($jsonUri), but it was empty or failed to parse.")
withContext(Dispatchers.Main) {
_processingStatus.value = PdfProcessingStatus.ERROR // Or a more specific error state
}
}
} catch (e: Exception) {
Timber.e(e, "Error loading timings after generation from $jsonUri")
withContext(Dispatchers.Main) {
_processingStatus.value = PdfProcessingStatus.ERROR
}
}
}
} ?: run {
Timber.e("MP3 generation succeeded but JSON URI was null. Cannot load timings/text.")
_processingStatus.value = PdfProcessingStatus.ERROR // Indicate error
}
// --- REMOVED Old timing load call ---
// viewModelScope.launch { loadTimingsAfterGeneration(progress.mp3Uri) }
// --- REMOVED Loading text from potentially stale repository ---
// _fullDocumentText.value = documentRepository.getFullText().value
}
is Mp3GenerationProgress.Error -> {
Timber.e(progress.cause, "MP3 Generation Error: ${progress.message}")
@ -442,11 +556,14 @@ class MainViewModel(
// --- Trigger Saving if Generation Succeeded ---
generationSuccessUri?.let { uriToSave ->
Timber.d("Generation successful. Triggering SaveMp3UseCase for URI: $uriToSave")
// Get the JSON URI from the success state
val jsonUriToSave = (_mp3GenerationProgress.value as? Mp3GenerationProgress.Success)?.jsonUri
// Don't set _isLoading here, saving progress has its own indicator
// Reset save progress state before starting
_mp3SaveProgress.value = FileSaveProgress.InProgress("Initiating save...")
saveMp3UseCase(uriToSave)
saveMp3UseCase(uriToSave, jsonUriToSave) // Pass both URIs
.catch { e ->
Timber.e(e, "Error collecting from SaveMp3UseCase flow")
_mp3SaveProgress.value = FileSaveProgress.Error("Save failed: ${e.message}", e)
@ -558,13 +675,8 @@ class MainViewModel(
private fun handlePlayMp3(mp3Uri: Uri? = null) {
Timber.d("handlePlayMp3 called.")
viewModelScope.launch {
val uriToPlay = mp3Uri ?: run {
// Fallback logic if called without a URI (e.g., from a UI button)
// We need a state holding the last generated content URI
// For now, let's log a warning. We need to add a state like _lastGeneratedMp3Uri
Timber.w("handlePlayMp3 called without explicit URI. Need to implement fallback logic.")
null // Return null if no URI provided and fallback isn't implemented
}
// Use the _currentMp3Uri state if no explicit URI is passed
val uriToPlay = mp3Uri ?: _currentMp3Uri.value
if (uriToPlay != null) {
try {
@ -614,11 +726,12 @@ class MainViewModel(
}
}
private fun handleSeekMp3(positionMs: Int) {
private fun handleSeekMp3(positionMs: Long) {
Timber.d("handleSeekMp3 called with position: $positionMs ms")
// No need for viewModelScope for simple seek
try {
seekMp3UseCase(positionMs.toLong()) // Convert Int to Long for the UseCase
// Now the types match, no conversion needed
seekMp3UseCase(positionMs)
} catch (e: Exception) {
Timber.e(e, "Error seeking MP3 playback")
}
@ -658,4 +771,37 @@ class MainViewModel(
// viewModelJob.cancel()
}
// Helper to load timings after successful MP3 generation
private suspend fun loadTimingsAfterGeneration(mp3Uri: Uri) {
Timber.d("Loading sentence timings after generation for URI: $mp3Uri")
// Assuming LoadAssociatedContentUseCase can also work if just the MP3 URI is known
// This relies on LoadAssociatedContentUseCase finding the metadata based on the MP3 URI's filename
// TODO: Revisit LoadAssociatedContentUseCase to handle this case more robustly if needed.
// TODO: Refactor: ViewModel should not resolve filenames or URIs.
/* // Temporarily commented out to remove Context dependency
val baseFileName = getBaseFileNameFromUri(mp3Uri) // Need helper here too
val metadataFileName = baseFileName?.let { "$it.json" } ?: run {
Timber.e("Could not get base filename from generated MP3 URI: $mp3Uri")
return
}
val metadataUri = fileSaverRepository.getUriForFileInPublicDirectory(context, metadataFileName)
if (metadataUri != null) {
val timings = loadSentenceTimingUseCase(metadataUri) // Use dedicated timing loader
if (timings.isNotEmpty()) {
_sentenceTimings.value = timings
Timber.i("Successfully loaded ${timings.size} sentence timings after generation.")
} else {
Timber.w("Loaded timing file after generation, but it was empty or failed to parse: $metadataUri")
}
} else {
Timber.e("Could not find metadata file ($metadataFileName) after MP3 generation ($mp3Uri)")
}
*/
Timber.w("loadTimingsAfterGeneration needs refactoring to avoid direct context/filename handling.")
}
// --- Removed Context Property and Helper Function ---
// Removed private val context: Context get() = ...
// Removed private fun getBaseFileNameFromUri(uri: Uri): String? ...
}