diff --git a/app/src/main/java/com/voca/app/ui/MainScreen.kt b/app/src/main/java/com/voca/app/ui/MainScreen.kt index 1651668..9d8052d 100644 --- a/app/src/main/java/com/voca/app/ui/MainScreen.kt +++ b/app/src/main/java/com/voca/app/ui/MainScreen.kt @@ -381,45 +381,37 @@ fun MainScreen( Spacer(modifier = Modifier.height(16.dp)) - // --- Conditionally Render Controls --- + // --- Use Unified Controls --- + UnifiedPlaybackControls( + ttsState = ttsState, + playbackState = playbackState, + isContentAvailable = isDocumentLoaded || hasUserInput, + isMp3Mode = isMp3FlowActive, // Determine if MP3 mode is active + onTogglePlayback = { viewModel.onAction(MainAction.TogglePlayback) }, // Pass the new action + onStopPlayback = { viewModel.onAction(MainAction.StopPlayback) } // Pass the new action + ) + // --- End Unified Controls --- + + // --- MP3 Generation/Save/Seek UI (Keep separate for now) --- if (isMp3FlowActive) { - // MP3 Player Controls - Only show if MP3 flow is active - Timber.d("Rendering MP3 Controls because isMp3FlowActive = true") - Mp3PlayerControls( - playbackState = playbackState, - mp3GenerationState = mp3GenerationState, - mp3SaveState = mp3SaveState, - currentMp3UriFromViewModel = currentMp3Uri, - // Enable generation button only if there's content and MP3 isn't already active/generating - generationEnabled = (isDocumentLoaded || hasUserInput) && !(playbackState.currentUri != null || mp3GenerationState is Mp3GenerationProgress.InProgress), - onPlay = { (viewModel::onAction)(MainAction.PlayMp3) }, - onPause = { (viewModel::onAction)(MainAction.PauseMp3) }, - onResume = { (viewModel::onAction)(MainAction.ResumeMp3) }, - onStop = { (viewModel::onAction)(MainAction.StopMp3) }, - onCancel = { (viewModel::onAction)(MainAction.CancelMp3Generation) }, - onSeek = { positionMs -> (viewModel::onAction)(MainAction.SeekMp3(positionMs)) }, - onGenerateMp3 = { (viewModel::onAction)(MainAction.GenerateMp3) } - ) - } else { - // TTS Playback Controls - Only show if MP3 flow is NOT active - Timber.d("Rendering TTS Controls because isMp3FlowActive = false") - TtsControls( - ttsState = ttsState, - // Enable TTS controls only if there is content available - enabled = isDocumentLoaded || hasUserInput, - onPlayPauseToggle = { - if (ttsState.isSpeaking && !ttsState.isPaused) { - (viewModel::onAction)(MainAction.PauseSpeech) - } else if (ttsState.isPaused) { - (viewModel::onAction)(MainAction.ResumeSpeech) - } else { - (viewModel::onAction)(MainAction.PlayText) // ViewModel will decide source - } - }, - onStop = { (viewModel::onAction)(MainAction.StopSpeaking) } - ) + Mp3GenerationAndSeekControls( + playbackState = playbackState, + mp3GenerationState = mp3GenerationState, + mp3SaveState = mp3SaveState, + currentMp3UriFromViewModel = currentMp3Uri, + onCancel = { (viewModel::onAction)(MainAction.CancelMp3Generation) }, + onSeek = { positionMs -> (viewModel::onAction)(MainAction.SeekMp3(positionMs)) }, + onGenerateMp3 = { (viewModel::onAction)(MainAction.GenerateMp3) } + ) + } else if (isDocumentLoaded || hasUserInput) { // Use the same condition used for `generationEnabled` previously + Button( + onClick = { viewModel.onAction(MainAction.GenerateMp3) }, + modifier = Modifier.padding(top = 8.dp) // Add some padding + ) { + Text("Create MP3") + } } - // --- + // --- End MP3 Generation/Save/Seek UI --- // Optional: Show subtle indicator if background audio is playing (Consider removing if controls are clear) /* @@ -438,38 +430,58 @@ fun MainScreen( } } -// Simple composable for TTS control buttons +// --- NEW Unified Playback Controls Composable --- @Composable -fun TtsControls( +fun UnifiedPlaybackControls( ttsState: TTSState, - enabled: Boolean, - onPlayPauseToggle: () -> Unit, - onStop: () -> Unit + playbackState: PlaybackState, + isContentAvailable: Boolean, + isMp3Mode: Boolean, // True if MP3 is loaded/active, false for TTS + onTogglePlayback: () -> Unit, + onStopPlayback: () -> Unit ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { + // Determine Play/Pause state based on mode + val isPlaying = if (isMp3Mode) playbackState.isPlaying else ttsState.isSpeaking && !ttsState.isPaused + val isPaused = if (isMp3Mode) playbackState.isPaused else ttsState.isPaused + val canPlayback = isContentAvailable || (isMp3Mode && playbackState.currentUri != null) // Enable if content or MP3 URI exists + // Play/Pause Button - IconButton(onClick = onPlayPauseToggle, enabled = enabled) { + IconButton(onClick = onTogglePlayback, enabled = canPlayback) { Icon( - imageVector = if (ttsState.isSpeaking && !ttsState.isPaused) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = if (ttsState.isSpeaking && !ttsState.isPaused) "Pause Speech" else "Play Speech", + imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = when { + isPlaying -> "Pause" + isPaused -> "Resume" + else -> "Play" + }, modifier = Modifier.size(48.dp) ) } - // Stop Button (Enabled only if speaking or paused) - IconButton(onClick = onStop, enabled = ttsState.isSpeaking || ttsState.isPaused) { + // Stop Button (Enabled if either TTS or MP3 is playing or paused) + val canStop = (isMp3Mode && (playbackState.isPlaying || playbackState.isPaused)) || + (!isMp3Mode && (ttsState.isSpeaking || ttsState.isPaused)) + IconButton(onClick = onStopPlayback, enabled = canStop) { Icon( imageVector = Icons.Filled.Stop, - contentDescription = "Stop Speech", + contentDescription = "Stop", modifier = Modifier.size(48.dp) ) } } } +// --- END Unified Playback Controls Composable --- + +// Simple composable for TTS control buttons - REMOVED +/* +@Composable +fun TtsControls(...) { ... } +*/ // --- Preview Data Structure --- // Simple data class to hold state for preview purposes, avoiding complex ViewModel mocking @@ -546,17 +558,14 @@ fun MainScreenPreviewDark() { Spacer(modifier = Modifier.height(16.dp)) } - TtsControls( - enabled = true, - ttsState = ttsState, - onPlayPauseToggle = { - if (ttsState.isSpeaking && !ttsState.isPaused) { - state.onAction(MainAction.PauseSpeech) - } else { - state.onAction(MainAction.PlayText) - } - }, - onStop = { state.onAction(MainAction.StopSpeaking) } + // Use UnifiedPlaybackControls in Preview + UnifiedPlaybackControls( + ttsState = ttsState, // Pass dummy state + playbackState = PlaybackState(), // Pass dummy state + isContentAvailable = text.isNotEmpty(), + isMp3Mode = false, // Assume TTS mode for preview + onTogglePlayback = { state.onAction(MainAction.TogglePlayback) }, // Use new action + onStopPlayback = { state.onAction(MainAction.StopPlayback) } // Use new action ) when(genState) { @@ -776,15 +785,6 @@ fun Mp3PlayerControls( } } -/** - * Formats milliseconds duration into MM:SS format. - */ -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. */ @@ -936,4 +936,142 @@ fun FullTextDisplayArea( ) } } -} \ No newline at end of file +} + +// --- NEW Composable for MP3 Generation and Seek Controls --- +@Composable +fun Mp3GenerationAndSeekControls( + playbackState: PlaybackState, + mp3GenerationState: Mp3GenerationProgress?, + mp3SaveState: FileSaveProgress?, + currentMp3UriFromViewModel: Uri?, // Keep for context, might be redundant + onCancel: () -> Unit, + onSeek: (Long) -> Unit, + onGenerateMp3: () -> Unit // Can likely remove this if generate button is outside +) { + val isGenerating = mp3GenerationState is Mp3GenerationProgress.InProgress + val isSaving = mp3SaveState is FileSaveProgress.InProgress + val isProcessing = isGenerating || isSaving // Combine generation and saving flags + + // Ready ONLY if NOT processing AND (saving succeeded OR playback service has a URI loaded) + val isMp3Ready = !isProcessing && + (mp3SaveState is FileSaveProgress.Success || playbackState.currentUri != null) + + val hasError = !isProcessing && ( // Only show errors *after* processing finishes + playbackState.error != null || + mp3GenerationState is Mp3GenerationProgress.Error || + mp3SaveState is FileSaveProgress.Error + ) + val isLoading = !isProcessing && !isMp3Ready && !hasError && playbackState.isLoading + + // Determine overall visibility: Show if processing, ready, loading, or has error + val shouldShowSection = isProcessing || isMp3Ready || isLoading || hasError + + if (shouldShowSection) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // --- Show Progress Indicator Section (Highest Priority) --- + if (isProcessing) { + val progress: Float = when { + isGenerating -> (mp3GenerationState as Mp3GenerationProgress.InProgress).progress + isSaving -> 0.5f // Indicate saving is happening, maybe use indeterminate later + else -> 0f + } + val progressText: String = when { + isGenerating -> "Creating MP3..." + isSaving -> "Saving MP3..." + else -> "Processing..." + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text(progressText, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(8.dp)) + LinearProgressIndicator( + progress = { progress }, // Use calculated progress + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + // Add an indeterminate bar if progress is low or saving + if (progress <= 0.01f || isSaving) { + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.secondary + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + // Show cancel button only during generation, not saving + if (isGenerating) { + Button( + onClick = onCancel, + enabled = mp3GenerationState is Mp3GenerationProgress.InProgress, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text("X") + } + } + } + // --- Show Seek Bar Section (Only if Ready and has duration) --- + } else if (isMp3Ready && playbackState.durationMs > 0) { + // Seek Bar and Time Display + Slider( + value = playbackState.currentPositionMs.toFloat(), + onValueChange = { onSeek(it.roundToLong()) }, + valueRange = 0f..playbackState.durationMs.toFloat(), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(formatDuration(playbackState.currentPositionMs), style = MaterialTheme.typography.labelSmall) + Text(formatDuration(playbackState.durationMs), style = MaterialTheme.typography.labelSmall) + } + + // --- Show Loading Indicator Section (Only if Loading) --- + } else if (isLoading) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.size(8.dp)) + Text("Loading MP3...", style = MaterialTheme.typography.bodyMedium) + } + // --- Show Error Section (Only if not processing, not ready, not loading, but has error) --- + } else if (hasError) { + val errorMsg = when { + mp3GenerationState is Mp3GenerationProgress.Error -> "Error generating MP3: ${mp3GenerationState.message}" + mp3SaveState is FileSaveProgress.Error -> "Error saving MP3: ${mp3SaveState.message}" + playbackState.error != null -> "Error loading MP3: ${playbackState.error}" + else -> "An unknown error occurred." + } + Text( + errorMsg, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} +// --- END MP3 Generation and Seek Controls Composable --- + +/** + * Formats milliseconds duration into MM:SS format. + */ +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) +} + +// ... existing code ... \ No newline at end of file diff --git a/app/src/main/java/com/voca/app/viewmodel/MainViewModel.kt b/app/src/main/java/com/voca/app/viewmodel/MainViewModel.kt index d8531ff..aab1159 100644 --- a/app/src/main/java/com/voca/app/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/voca/app/viewmodel/MainViewModel.kt @@ -63,20 +63,31 @@ sealed interface MainAction { data class ProcessSelectedDocument(val uri: Uri) : MainAction data class ProcessUri(val uri: Uri) : MainAction data class ProcessSharedText(val text: String) : MainAction - data object PlayText : MainAction // Plays current page via TTS - data object PauseSpeech : MainAction - data object ResumeSpeech : MainAction - data object StopSpeaking : MainAction - data class SetTextAndPlay(val text: String) : MainAction // Set text and immediately play TTS + data class SetTextAndPlay(val text: String) : MainAction // Set text and immediately play TTS - Keep for specific use cases + + // --- NEW Unified Playback Actions --- + data object TogglePlayback : MainAction // Unified Play/Pause/Resume + data object StopPlayback : MainAction // Unified Stop + // --- END Unified Actions --- + + // --- REMOVED Specific Playback Actions --- + // data object PlayText : MainAction + // data object PauseSpeech : MainAction + // data object ResumeSpeech : MainAction + // data object StopSpeaking : MainAction + // data object PlayMp3 : MainAction + // data object PauseMp3 : MainAction + // data object ResumeMp3 : MainAction + // data object StopMp3 : MainAction + // --- END REMOVED --- + data object GenerateMp3 : MainAction data object CancelMp3Generation : MainAction // New action to cancel MP3 generation data class SaveMp3(val desiredFileName: String? = null) : MainAction // Optional desired filename - data object PlayMp3 : MainAction // Plays the last generated/loaded MP3 - data object PauseMp3 : MainAction - data object ResumeMp3 : MainAction - data object StopMp3 : MainAction - // Change parameter type to Long + + // Keep Seek action for MP3 data class SeekMp3(val positionMs: Long) : MainAction + data object NextPage : MainAction data object PreviousPage : MainAction data object TriggerDocumentPicker : MainAction @@ -332,18 +343,16 @@ class MainViewModel( is MainAction.ProcessUri -> handleLoadDocumentAndInitiateAudioProcessing(action.uri) is MainAction.ProcessSharedText -> handleUpdateText(action.text) is MainAction.UpdateText -> handleUpdateText(action.text) - is MainAction.SetTextAndPlay -> handleSetTextAndPlay(action.text) // Processes text then speaks + is MainAction.SetTextAndPlay -> handleSetTextAndPlay(action.text) is MainAction.UpdateInputText -> { _userInputText.value = action.text } is MainAction.TriggerDocumentPicker -> handleTriggerDocumentPicker() - // TTS Actions - MainAction.PlayText -> handlePlayText() // Plays current page - MainAction.PauseSpeech -> handlePauseSpeech() - MainAction.ResumeSpeech -> handleResumeSpeech() - MainAction.StopSpeaking -> handleStopSpeaking() - // MainAction.ToggleTTS -> handleToggleTTS() // Removed as separate Pause/Resume/Play are preferred + // --- UPDATED Playback Actions --- + MainAction.TogglePlayback -> handleTogglePlayback() + MainAction.StopPlayback -> handleStopPlayback() + // --- END UPDATED --- // MP3 Generation & Saving (Now combined or manual) MainAction.GenerateMp3 -> handleGenerateAndSaveMp3() // Manual trigger for combined process @@ -351,10 +360,6 @@ class MainViewModel( is MainAction.SaveMp3 -> Unit // Explicitly ignore if saving is purely automatic now // MP3 Playback - MainAction.PlayMp3 -> handlePlayMp3() - MainAction.PauseMp3 -> handlePauseMp3() - MainAction.ResumeMp3 -> handleResumeMp3() - MainAction.StopMp3 -> handleStopMp3() is MainAction.SeekMp3 -> handleSeekMp3(action.positionMs) // Pagination @@ -519,41 +524,54 @@ class MainViewModel( } } - private fun handlePlayText() { - Timber.d("handlePlayText called.") + // --- NEW Handler for Unified Playback Toggle --- + private fun handleTogglePlayback() { + Timber.d("handleTogglePlayback called.") viewModelScope.launch { - // --- ADD CHECK: Do not start TTS if an MP3 is loaded --- - if (_currentMp3Uri.value != null) { - Timber.w("handlePlayText: MP3 URI is loaded ($_currentMp3Uri.value). Skipping TTS playback.") - return@launch - } - // --- END CHECK --- + val mp3Uri = _currentMp3Uri.value + val currentPlaybackState = playbackState.value + val currentTtsState = ttsStateFlow.value - val textToSpeak = getCurrentDocumentStateUseCase.currentPageText.value - if (!textToSpeak.isNullOrBlank()) { - Timber.d("Speaking current page text: '${textToSpeak.take(50)}...'") - speakTextUseCase(textToSpeak) + if (mp3Uri != null) { + // --- MP3 Mode --- + Timber.d("Toggle playback in MP3 mode. State: $currentPlaybackState") + when { + currentPlaybackState.isPlaying -> pauseMp3UseCase() + currentPlaybackState.isPaused -> resumeMp3UseCase() + else -> playMp3UseCase(mp3Uri) // Start playback if idle + } } else { - Timber.w("PlayText action called but no text available (document or input).") + // --- TTS Mode --- + Timber.d("Toggle playback in TTS mode. State: $currentTtsState") + val textToSpeak = getCurrentDocumentStateUseCase.currentPageText.value + if (!textToSpeak.isNullOrBlank()) { + when { + currentTtsState.isSpeaking && !currentTtsState.isPaused -> pauseTTSUseCase() + currentTtsState.isPaused -> resumeTTSUseCase() + else -> speakTextUseCase(textToSpeak) // Start TTS if idle + } + } else { + Timber.w("TogglePlayback (TTS): No text available.") + // Optionally emit an error or show a message + } } } } + // --- END Handler --- - private fun handlePauseSpeech() { - Timber.d("handlePauseSpeech called.") - pauseTTSUseCase() - } - - private fun handleResumeSpeech() { - Timber.d("handleResumeSpeech called.") - resumeTTSUseCase() - } - - private fun handleStopSpeaking() { - Timber.d("handleStopSpeaking called.") - stopTTSUseCase() + // --- NEW Handler for Unified Stop --- + private fun handleStopPlayback() { + Timber.d("handleStopPlayback called.") + viewModelScope.launch { + // Stop both TTS and MP3 playback regardless of the current mode + Timber.d("Stopping both TTS and MP3.") + stopTTSUseCase() + stopMp3UseCase() + } } + // --- END Handler --- + // --- ADDED BACK: Handler for SetTextAndPlay --- private fun handleSetTextAndPlay(text: String) { Timber.d("handleSetTextAndPlay called.") if (text.isBlank()) { @@ -566,17 +584,20 @@ class MainViewModel( resetStateForNewContent() // Reset state FIRST delay(50) // Small delay to allow state reset propagation if needed - _mp3SaveProgress.value = null // Reset save state + + _isLoading.value = true + _processingStatus.value = PdfProcessingStatus.PROCESSING_TEXT try { processDocumentUseCase.processText(text) delay(50) // Keep delay for now, but ideally improve state propagation val updatedText = getCurrentDocumentStateUseCase.currentPageText.value if (!updatedText.isNullOrBlank()) { - speakTextUseCase(updatedText) - _processingStatus.value = PdfProcessingStatus.COMPLETED + // Initiate TTS playback after processing + speakTextUseCase(updatedText) + _processingStatus.value = PdfProcessingStatus.COMPLETED // Mark as completed after starting TTS } else { Timber.w("handleSetTextAndPlay: Text set, but state not updated in time for TTS.") - _processingStatus.value = PdfProcessingStatus.COMPLETED + _processingStatus.value = PdfProcessingStatus.COMPLETED // Mark as completed even if TTS didn't start } } catch (e: Exception) { Timber.e(e, "Error in setTextAndPlay") @@ -586,6 +607,7 @@ class MainViewModel( } } } + // --- END ADDED BACK --- private fun handleGenerateAndSaveMp3() { Timber.d("handleGenerateAndSaveMp3 called. Adding debug checkpoints.") @@ -818,60 +840,6 @@ class MainViewModel( } } - private fun handlePlayMp3(mp3Uri: Uri? = null) { - Timber.d("handlePlayMp3 called.") - viewModelScope.launch { - // Use the _currentMp3Uri state if no explicit URI is passed - val uriToPlay = mp3Uri ?: _currentMp3Uri.value - - if (uriToPlay != null) { - try { - Timber.d("Attempting to play MP3 from URI: $uriToPlay") - Timber.i("--> [VM] handlePlayMp3: Calling playMp3UseCase for URI: $uriToPlay") - // Call use case with the correct URI - playMp3UseCase(uriToPlay) - } catch (e: Exception) { - Timber.e(e, "Error starting MP3 playback for URI: $uriToPlay") - } - } else { - Timber.w("Play MP3 requested, but no URI available.") - } - } - } - - private fun handlePauseMp3() { - Timber.d("handlePauseMp3 called.") - viewModelScope.launch { - try { - pauseMp3UseCase() - } catch (e: Exception) { - Timber.e(e, "Error pausing MP3 playback") - } - } - } - - private fun handleResumeMp3() { - Timber.d("handleResumeMp3 called.") - viewModelScope.launch { - try { - resumeMp3UseCase() - } catch (e: Exception) { - Timber.e(e, "Error resuming MP3 playback") - } - } - } - - private fun handleStopMp3() { - Timber.d("handleStopMp3 called.") - viewModelScope.launch { - try { - stopMp3UseCase() - } catch (e: Exception) { - Timber.e(e, "Error stopping MP3 playback") - } - } - } - private fun handleSeekMp3(positionMs: Long) { Timber.d("handleSeekMp3 called with position: $positionMs ms") // No need for viewModelScope for simple seek