fix: load text and play audio for selected MP3/JSON pair
This commit is contained in:
parent
742aae923b
commit
babf28e77c
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 ---
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user