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.MainAction
import com.voca.app.viewmodel.MainViewModel import com.voca.app.viewmodel.MainViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber import timber.log.Timber
import com.voca.app.data.prefs.UserPreferences import com.voca.app.data.prefs.UserPreferences
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
/** /**
* Main activity for the Voca application * Main activity for the Voca application
@ -35,13 +37,24 @@ class MainActivity : ComponentActivity() {
// Inject UserPreferences // Inject UserPreferences
private val userPreferences: UserPreferences by inject() private val userPreferences: UserPreferences by inject()
// Register document picker for a result // Register original document picker for a result
private val documentLauncher = registerForActivityResult( private val documentLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument() OpenDocument()
) { uri -> ) { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -51,13 +64,29 @@ class MainActivity : ComponentActivity() {
// diagnoseAndRepairTts() // diagnoseAndRepairTts()
// --- Observe ViewModel Events --- // --- Observe ViewModel Events ---
// Original document picker event
lifecycleScope.launch { lifecycleScope.launch {
mainViewModel.openDocumentPickerEvent.collect { mainViewModel.openDocumentPickerEvent.collect { _ ->
Timber.d("MainActivity received openDocumentPickerEvent. Launching picker.") Timber.d("MainActivity received openDocumentPickerEvent. Launching picker.")
openDocumentPicker() // Call the Activity's picker function 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 { setContent {
Timber.d("MainActivity setContent called. Initializing UI.") Timber.d("MainActivity setContent called. Initializing UI.")

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

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

@ -109,6 +109,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import androidx.compose.material.icons.filled.UploadFile
/** /**
* MainScreen is the primary UI component for the Voca app * MainScreen is the primary UI component for the Voca app
@ -293,8 +294,15 @@ fun MainScreen(
actions = { actions = {
// Open Document Button // Open Document Button
IconButton(onClick = { viewModel.onAction(MainAction.TriggerDocumentPicker) }) { 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 // Settings Button
IconButton(onClick = { IconButton(onClick = {
// Navigate to Android TTS Settings // 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.usecase.LoadSentenceTimingUseCase
import com.voca.app.domain.model.SentenceTiming import com.voca.app.domain.model.SentenceTiming
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.SharedFlow
// --- Define UI Actions using a Sealed Interface (RULE-010) --- // --- Define UI Actions using a Sealed Interface (RULE-010) ---
sealed interface MainAction { sealed interface MainAction {
@ -80,6 +81,13 @@ sealed interface MainAction {
data object PreviousPage : MainAction data object PreviousPage : MainAction
data object TriggerDocumentPicker : MainAction data object TriggerDocumentPicker : MainAction
data class UpdateInputText(val text: String) : MainAction // New action 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 // Add other actions as needed
} }
// --- // ---
@ -129,6 +137,21 @@ class MainViewModel(
private val _userInputText = MutableStateFlow("") private val _userInputText = MutableStateFlow("")
val userInputText: StateFlow<String> = _userInputText.asStateFlow() 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 { init {
Timber.d("MainViewModel instance created: ${this.hashCode()}") Timber.d("MainViewModel instance created: ${this.hashCode()}")
@ -340,6 +363,12 @@ class MainViewModel(
// NOTE: GoToPage action removed // NOTE: GoToPage action removed
// NOTE: UpdateTtsSettings 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 --- // --- 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 ---
} }