fix: load text and play audio for selected MP3/JSON pair

This commit is contained in:
dave 2025-04-08 17:22:49 +03:00
parent 742aae923b
commit babf28e77c
5 changed files with 192 additions and 40 deletions
app/src/main/java/com/voca/app

@ -19,10 +19,12 @@ import com.voca.app.ui.theme.VocaTheme
import com.voca.app.viewmodel.MainAction
import com.voca.app.viewmodel.MainViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
import com.voca.app.data.prefs.UserPreferences
import org.koin.android.ext.android.inject
import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
/**
* Main activity for the Voca application
@ -35,13 +37,24 @@ class MainActivity : ComponentActivity() {
// Inject UserPreferences
private val userPreferences: UserPreferences by inject()
// Register document picker for a result
// Register original document picker for a result
private val documentLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
OpenDocument()
) { uri ->
uri?.let { processSelectedDocument(it) }
// Use the correct action name from MainViewModel.kt
uri?.let { mainViewModel.onAction(MainAction.ProcessSelectedDocument(it)) }
}
// --- ADDED: Launchers for explicit MP3/JSON selection ---
private val mp3Launcher = registerForActivityResult(OpenDocument()) { uri ->
uri?.let { mainViewModel.onAction(MainAction.Mp3FileSelectedForPair(it)) }
}
private val jsonLauncher = registerForActivityResult(OpenDocument()) { uri ->
uri?.let { mainViewModel.onAction(MainAction.JsonFileSelectedForPair(it)) }
}
// --- END ADDED ---
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -51,13 +64,29 @@ class MainActivity : ComponentActivity() {
// diagnoseAndRepairTts()
// --- Observe ViewModel Events ---
// Original document picker event
lifecycleScope.launch {
mainViewModel.openDocumentPickerEvent.collect {
mainViewModel.openDocumentPickerEvent.collect { _ ->
Timber.d("MainActivity received openDocumentPickerEvent. Launching picker.")
openDocumentPicker() // Call the Activity's picker function
}
}
// ---
// --- ADDED: Observe events for manual pair selection ---
lifecycleScope.launch {
mainViewModel.openMp3PickerEvent.collect { _ ->
Timber.d("MainActivity received openMp3PickerEvent. Launching MP3 picker.")
mp3Launcher.launch(arrayOf("audio/mpeg")) // Specify MP3 MIME type
}
}
lifecycleScope.launch {
mainViewModel.openJsonPickerEvent.collect { _ ->
Timber.d("MainActivity received openJsonPickerEvent. Launching JSON picker.")
jsonLauncher.launch(arrayOf("application/json")) // Specify JSON MIME type
}
}
// --- END ADDED ---
setContent {
Timber.d("MainActivity setContent called. Initializing UI.")

@ -140,7 +140,7 @@ val domainModule = module {
// --- New Use Cases for Sentence Timing & Loading ---
factory { CheckForExistingMediaUseCase(get(), androidContext()) }
factory { LoadSentenceTimingUseCase(get(), androidContext()) }
factory { LoadSentenceTimingUseCase(androidContext()) }
factory { LoadAssociatedContentUseCase(get(), get(), get(), androidContext()) }
// --- End New Use Cases ---

@ -2,14 +2,15 @@ package com.voca.app.domain.usecase
import android.content.Context
import android.net.Uri
import com.voca.app.domain.model.SentenceTimingInfo
import com.voca.app.domain.repository.IFileSaverRepository
// Remove unused import: import com.voca.app.domain.model.SentenceTimingInfo
// Remove unused import: import com.voca.app.domain.repository.IFileSaverRepository
import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString
// Remove unused import: import kotlinx.serialization.decodeFromString
import timber.log.Timber
import java.io.InputStreamReader
import com.voca.app.domain.model.SentenceTiming
import com.voca.app.domain.model.TimingMetadata
import kotlinx.serialization.SerializationException
// Basic loader, assumes simple JSON list format
// TODO: Move to a dedicated data layer component if complexity grows
@ -17,6 +18,7 @@ object SentenceTimingLoader {
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }
fun load(context: Context, metadataUri: Uri): List<SentenceTiming> {
Timber.d("Attempting to load timings from URI: $metadataUri")
return try {
context.contentResolver.openInputStream(metadataUri)?.use { inputStream ->
InputStreamReader(inputStream).use { reader ->
@ -25,11 +27,19 @@ object SentenceTimingLoader {
Timber.w("Metadata file is empty: $metadataUri")
emptyList()
} else {
// Parse the full TimingMetadata object
val timingMetadata = json.decodeFromString<TimingMetadata>(jsonString)
Timber.d("Parsed TimingMetadata version: ${timingMetadata.version}, sentence count: ${timingMetadata.sentences.size}")
// Return the list of SentenceTiming objects
timingMetadata.sentences
try {
// Parse the full TimingMetadata object
val timingMetadata = json.decodeFromString<TimingMetadata>(jsonString)
Timber.d("Parsed TimingMetadata version: ${timingMetadata.version}, sentence count: ${timingMetadata.sentences.size}")
// Return the list of SentenceTiming objects
timingMetadata.sentences
} catch (e: SerializationException) {
Timber.e(e, "JSON parsing failed for URI: $metadataUri. Content starts: ${jsonString.take(100)}")
emptyList()
} catch (e: Exception) {
Timber.e(e, "Unexpected error during JSON decoding for URI: $metadataUri")
emptyList()
}
}
}
} ?: run {
@ -43,38 +53,24 @@ object SentenceTimingLoader {
}
}
// Modify the UseCase to accept the JSON URI directly
class LoadSentenceTimingUseCase(
private val fileSaverRepository: IFileSaverRepository,
private val context: Context // Need context for ContentResolver
private val context: Context // Need context for ContentResolver passed to loader
) {
suspend operator fun invoke(mp3Uri: Uri): List<SentenceTiming> {
// Derive metadata filename from MP3 filename
val mp3FileName = getFileNameFromUri(mp3Uri)
if (mp3FileName == null) {
Timber.w("Could not get filename from MP3 URI: $mp3Uri")
return emptyList()
}
val metadataFileName = mp3FileName.replaceAfterLast('.', "json", "$mp3FileName.json")
return try {
val metadataUri = fileSaverRepository.getUriForFileInPublicDirectory(context, metadataFileName)
if (metadataUri != null) {
Timber.d("Found metadata file URI: $metadataUri")
SentenceTimingLoader.load(context, metadataUri)
} else {
Timber.w("Metadata file not found for $mp3FileName (looked for $metadataFileName)")
emptyList()
}
} catch (e: Exception) {
Timber.e(e, "Failed to load timing metadata for $metadataFileName")
emptyList()
}
// Change parameter to jsonUri
suspend operator fun invoke(jsonUri: Uri): List<SentenceTiming> {
// Remove logic for deriving filename and using fileSaverRepository
// Directly use the provided jsonUri with the loader
Timber.d("LoadSentenceTimingUseCase invoked with explicit JSON URI: $jsonUri")
return SentenceTimingLoader.load(context, jsonUri)
}
// Helper to get filename (consider moving to a shared util if used elsewhere)
// Remove the unused getFileNameFromUri helper method
/*
private fun getFileNameFromUri(uri: Uri): String? {
// Basic implementation, assumes URI path segment is the filename
// A more robust implementation might use ContentResolver query
return uri.lastPathSegment
}
*/
}

@ -109,6 +109,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import androidx.compose.material.icons.filled.UploadFile
/**
* MainScreen is the primary UI component for the Voca app
@ -293,8 +294,15 @@ fun MainScreen(
actions = {
// Open Document Button
IconButton(onClick = { viewModel.onAction(MainAction.TriggerDocumentPicker) }) {
Icon(Icons.Filled.FolderOpen, contentDescription = "Open Document")
Icon(Icons.Filled.FolderOpen, contentDescription = "Load Document/Text")
}
// --- ADDED: Button for MP3/JSON Pair Loading ---
IconButton(onClick = { viewModel.onAction(MainAction.SelectMediaPair) }) {
Icon(Icons.Filled.UploadFile, contentDescription = "Load MP3 + JSON Pair")
}
// --- END ADDED ---
// Settings Button
IconButton(onClick = {
// Navigate to Android TTS Settings

@ -55,6 +55,7 @@ import com.voca.app.domain.repository.IFileSaverRepository
import com.voca.app.domain.usecase.LoadSentenceTimingUseCase
import com.voca.app.domain.model.SentenceTiming
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.SharedFlow
// --- Define UI Actions using a Sealed Interface (RULE-010) ---
sealed interface MainAction {
@ -80,6 +81,13 @@ sealed interface MainAction {
data object PreviousPage : MainAction
data object TriggerDocumentPicker : MainAction
data class UpdateInputText(val text: String) : MainAction // New action
// --- ADDED: Actions for MP3/JSON Pairing ---
data object SelectMediaPair : MainAction // Initiates the pairing process
data class Mp3FileSelectedForPair(val uri: Uri) : MainAction // After MP3 is chosen
data class JsonFileSelectedForPair(val uri: Uri) : MainAction // After JSON is chosen
// --- END ADDED ---
// Add other actions as needed
}
// ---
@ -129,6 +137,21 @@ class MainViewModel(
private val _userInputText = MutableStateFlow("")
val userInputText: StateFlow<String> = _userInputText.asStateFlow()
// --- ADDED: State/Events for MP3/JSON Pairing ---
private val _openMp3PickerEvent = MutableSharedFlow<Unit>()
val openMp3PickerEvent: SharedFlow<Unit> = _openMp3PickerEvent.asSharedFlow()
private val _openJsonPickerEvent = MutableSharedFlow<Unit>()
val openJsonPickerEvent: SharedFlow<Unit> = _openJsonPickerEvent.asSharedFlow()
private val _selectedMp3UriForPair = MutableStateFlow<Uri?>(null)
// --- END ADDED ---
// --- ADDED: UI Error Flow (ensure it exists) ---
private val _uiError = MutableSharedFlow<String>()
val uiError: SharedFlow<String> = _uiError.asSharedFlow()
// ---
init {
Timber.d("MainViewModel instance created: ${this.hashCode()}")
@ -340,6 +363,12 @@ class MainViewModel(
// NOTE: GoToPage action removed
// NOTE: UpdateTtsSettings action removed
// --- ADDED: Actions for MP3/JSON Pairing ---
MainAction.SelectMediaPair -> handleSelectMediaPair()
is MainAction.Mp3FileSelectedForPair -> handleMp3FileSelectedForPair(action.uri)
is MainAction.JsonFileSelectedForPair -> handleJsonFileSelectedForPair(action)
// --- END ADDED ---
}
}
}
@ -989,4 +1018,94 @@ class MainViewModel(
// --- End Playback Tracking Helpers ---
// --- ADDED: Handler Implementations for MP3/JSON Pairing ---
private fun handleSelectMediaPair() {
Timber.d("handleSelectMediaPair: Triggering MP3 picker.")
viewModelScope.launch {
_openMp3PickerEvent.emit(Unit)
}
}
private fun handleMp3FileSelectedForPair(uri: Uri) {
viewModelScope.launch {
Timber.d("MP3 file selected for pair: $uri")
_selectedMp3UriForPair.value = uri
Timber.d("_selectedMp3UriForPair state updated.")
_openJsonPickerEvent.emit(Unit)
Timber.d("JSON picker event emitted.")
}
}
private fun handleJsonFileSelectedForPair(action: MainAction.JsonFileSelectedForPair) {
val jsonUri = action.uri
val mp3Uri = _selectedMp3UriForPair.value
Timber.d("JSON file selected for pair: $jsonUri")
Timber.d("Retrieved MP3 URI for playback: $mp3Uri")
if (mp3Uri == null) {
Timber.e("Cannot proceed with JSON pairing, MP3 URI is null!")
viewModelScope.launch {
_uiError.emit("Error: MP3 file selection was lost. Please try again.")
}
return
}
// Reset MP3 selection state after retrieving it
// _selectedMp3UriForPair.value = null // Optional: Reset if needed
viewModelScope.launch {
// Use a more specific status for loading the pair
_processingStatus.value = PdfProcessingStatus.LOADING_ASSOCIATED_CONTENT // Indicate loading
try {
Timber.d("Loading sentence timings from selected JSON: $jsonUri")
val timings = loadSentenceTimingUseCase(jsonUri)
if (timings.isNotEmpty()) {
// Store the timings specifically for highlighting
_sentenceTimings.value = timings
Timber.d("Successfully loaded ${timings.size} sentence timings.")
val reconstructedText = timings.joinToString(separator = " ") { it.text ?: "" }.trim()
// Store the full text separately if needed, or rely on processDocumentUseCase
_fullDocumentText.value = reconstructedText
Timber.d("Reconstructed text: '${reconstructedText.take(100)}...' (Length: ${reconstructedText.length})")
if (reconstructedText.isEmpty()) {
Timber.w("Timings loaded, but reconstructed text is empty. Check SentenceTiming model or JSON content.")
// Proceed? Or error out? For now, proceed but log.
}
// Clear any previous TTS state
stopTTSUseCase()
// --- Use ProcessDocumentUseCase to update the main document state ---
Timber.d("Updating document state with reconstructed text.")
try {
processDocumentUseCase.processText(reconstructedText)
// State should now be updated in the repository, triggering UI updates
_processingStatus.value = PdfProcessingStatus.READY_TO_PLAY // Use the READY state
Timber.d("Document state updated via use case. Attempting to play MP3: $mp3Uri")
playMp3UseCase(mp3Uri) // Play after state is set
} catch (processError: Exception) {
Timber.e(processError, "Error processing reconstructed text via use case.")
_processingStatus.value = PdfProcessingStatus.ERROR
_uiError.emit("Error setting document content.")
}
// --- End Use Case Update ---
} else {
Timber.e("No sentence timings found or loaded from the selected JSON: $jsonUri")
_processingStatus.value = PdfProcessingStatus.ERROR
_uiError.emit("Error: Could not load timings from JSON file.")
}
} catch (e: CancellationException) {
Timber.w(e, "Pairing process cancelled for JSON: $jsonUri")
_processingStatus.value = PdfProcessingStatus.IDLE
} catch (e: Exception) {
Timber.e(e, "Error during JSON processing or MP3 playback initiation for $jsonUri")
_processingStatus.value = PdfProcessingStatus.ERROR
_uiError.emit("Error processing files: ${e.localizedMessage}")
}
}
}
// --- END ADDED ---
}