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:
parent
57ec7f1b74
commit
d98fc5ec50
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? ...
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user