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.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 ---
|
||||||
|
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user