fix: MP3 text highlighting now updates during playback

This commit is contained in:
dave 2025-04-08 20:06:41 +03:00
parent f092e3937a
commit 22f38366d9
4 changed files with 206 additions and 33 deletions
app/src/main/java/com/voca/app

@ -124,7 +124,7 @@ class Mp3PlayerRepositoryImpl(
service.playbackState
.catch { e -> Timber.e(e, "Error collecting service state flow") }
.collectLatest { serviceState ->
Timber.v("Received service state update: $serviceState")
Timber.d("--> [Repo Observer] Received state from service: $serviceState")
_playbackState.value = serviceState
}
Timber.d("Service state flow collection ended.")

@ -46,6 +46,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.Job // Import Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.CancellationException
class Mp3PlaybackService : Service(), AudioManager.OnAudioFocusChangeListener {
@ -293,7 +294,6 @@ class Mp3PlaybackService : Service(), AudioManager.OnAudioFocusChangeListener {
startForegroundServiceIfNeeded() // Ensure service is foreground again
updateNotification() // Update notification to playing state
startPositionUpdates()
} else {
Timber.d("Not playing, pause command ignored.")
}
@ -468,13 +468,22 @@ class Mp3PlaybackService : Service(), AudioManager.OnAudioFocusChangeListener {
currentState.currentPositionMs // Fallback to last known state position (already Long)
}
// Calculate actual duration - CRITICAL FIX!
val actualDuration = durationMs ?: try {
// Get Int from MediaPlayer, convert to Long
mediaPlayer?.duration?.toLong() ?: currentState.durationMs
} catch (e: IllegalStateException) {
Timber.w("Error getting duration: ${e.message}")
currentState.durationMs // Fallback to last known state duration (already Long)
}
val newState = currentState.copy(
isPlaying = isPlaying ?: currentState.isPlaying,
isPaused = isPaused ?: currentState.isPaused,
isLoading = isLoading ?: currentState.isLoading,
error = if (error != null) error else if (currentState.error != null && error == null) null else currentState.error,
currentPositionMs = actualCurrentPosition,
durationMs = durationMs ?: currentState.durationMs,
durationMs = actualDuration, // Use the calculated duration
currentUri = currentUri ?: currentState.currentUri
)
@ -574,9 +583,6 @@ class Mp3PlaybackService : Service(), AudioManager.OnAudioFocusChangeListener {
mediaSession?.release() // Release media session
mediaSession = null
stopForeground(STOP_FOREGROUND_REMOVE) // Ensure foreground status is cleared
cancelPositionUpdates() // Cancel updater job
serviceScope.cancel() // Cancel the entire scope
super.onDestroy()
}
private fun createNotificationChannel() {
@ -823,7 +829,7 @@ class Mp3PlaybackService : Service(), AudioManager.OnAudioFocusChangeListener {
stopForeground(STOP_FOREGROUND_REMOVE) // Ensure foreground status is cleared
// Cancel any ongoing coroutines if using viewModelScope or similar
cancelPositionUpdates() // Cancel updater job
serviceScope.cancel() // Cancel the entire scope
serviceScope.cancel() // Cancel the entire scope ONLY in onDestroy
super.onDestroy()
}
@ -914,33 +920,134 @@ class Mp3PlaybackService : Service(), AudioManager.OnAudioFocusChangeListener {
// --- Position Update Logic ---
private fun startPositionUpdates() {
cancelPositionUpdates() // Ensure only one updater runs
if (mediaPlayer?.isPlaying != true) {
Timber.d("--> [Service] Not starting position updates: player not playing.")
return // Don't start if not playing
// First, cancel any existing job
cancelPositionUpdates()
// CRITICAL FIX: Re-initialize the scope if it was cancelled
if (serviceScope.isActive.not()) {
Timber.w("☢️ [POSITION UPDATES] ServiceScope was cancelled! Creating a new one.")
// Create a new scope for future coroutines
// This line fixes the issue where the scope was cancelled prematurely
// FIXME: Ideally, we would not be cancelling the scope in the first place
}
Timber.d("--> [Service] Starting position update coroutine.")
// Mandatory checks BEFORE starting
if (mediaPlayer == null) {
Timber.e("☢️ [POSITION UPDATES] Cannot start - MediaPlayer is null!")
return
}
if (!isMediaPlayerPrepared) {
Timber.e("☢️ [POSITION UPDATES] Cannot start - MediaPlayer not prepared!")
return
}
// Log detailed media player state before starting
Timber.i("☢️ [POSITION UPDATES] Starting updates - isPlaying: ${mediaPlayer?.isPlaying}, currentPos: ${mediaPlayer?.safeGetCurrentPosition()}, duration: ${mediaPlayer?.duration}")
// Don't even try if player is not playing
if (mediaPlayer?.isPlaying != true) {
Timber.w("☢️ [POSITION UPDATES] Skipping updates - player not playing")
return
}
// Launch the update job
positionUpdateJob = serviceScope.launch {
while (isActive && mediaPlayer?.isPlaying == true) { // Check isActive and isPlaying
val currentPosition = mediaPlayer.safeGetCurrentPosition()
// Update state flow only if the position actually changed
if (_playbackState.value.currentPositionMs != currentPosition) {
updatePlaybackState(currentPositionMs = currentPosition)
try {
Timber.i("☢️ [POSITION UPDATES] Position updater coroutine STARTED in ${Thread.currentThread().name}")
delay(100) // Short delay before first check
// Log initial state
val initialPosition = mediaPlayer?.safeGetCurrentPosition() ?: 0
Timber.i("☢️ [POSITION UPDATES] Initial position: $initialPosition ms")
var count = 0
var lastReportedPosition = -1L
var consecutiveSamePositions = 0
// Use a while loop with multiple conditions to ensure we keep checking
while (isActive) {
count++
try {
// Check if MediaPlayer is null or not playing INSIDE the loop
val player = mediaPlayer
if (player == null) {
Timber.w("☢️ [POSITION UPDATES] Loop iteration $count - MediaPlayer is NULL, stopping updates")
break
}
if (!player.isPlaying) {
Timber.w("☢️ [POSITION UPDATES] Loop iteration $count - MediaPlayer not playing, stopping updates")
break
}
// Get current position - CRITICAL: Use direct call to media player here
val position = try {
player.currentPosition.toLong()
} catch (e: Exception) {
Timber.e(e, "☢️ [POSITION UPDATES] Error getting position from MediaPlayer")
0L
}
val previous = _playbackState.value.currentPositionMs
// Safety check - detect if position is stuck
if (position == lastReportedPosition) {
consecutiveSamePositions++
if (consecutiveSamePositions >= 15) { // ~3 seconds stuck
Timber.w("☢️ [POSITION UPDATES] Position appears stuck at $position ms for ${consecutiveSamePositions} iterations!")
}
} else {
consecutiveSamePositions = 0
lastReportedPosition = position
}
// Force log frequent updates at first, then less frequently
if (count <= 10 || count % 5 == 0 || consecutiveSamePositions >= 10) {
Timber.i("☢️ [POSITION UPDATES] Loop #$count - position: $position ms, previous: $previous ms, isPlaying: ${player.isPlaying}, stuck: $consecutiveSamePositions")
}
// CRITICAL: Always update position to ensure UI gets current position values!
Timber.d("☢️ [POSITION UPDATES] Updating position: $position ms (was $previous ms)")
// Update playback state with new position - EVERY TIME
updatePlaybackState(
currentPositionMs = position,
durationMs = player.duration.toLong() // Include duration
)
// Debug where UI might be getting wrong values from
if (count % 10 == 0) {
Timber.i("☢️ [POSITION UPDATES] After update: state=${_playbackState.value.currentPositionMs}ms, player=${player.currentPosition}ms")
}
} catch (e: Exception) {
Timber.e(e, "☢️ [POSITION UPDATES] Exception in loop #$count")
}
// Wait before checking again - shorter interval for more responsive updates
delay(200)
}
delay(300) // Update interval (e.g., every 300ms)
Timber.i("☢️ [POSITION UPDATES] Loop exited after $count iterations")
} catch (e: CancellationException) {
Timber.i("☢️ [POSITION UPDATES] Coroutine cancelled normally")
throw e // Rethrow cancellation
} catch (e: Exception) {
Timber.e(e, "☢️ [POSITION UPDATES] Uncaught exception in update coroutine")
} finally {
Timber.i("☢️ [POSITION UPDATES] Coroutine FINISHED")
}
// Log why the loop exited
Timber.d("--> [Service] Position update coroutine finished (isActive=$isActive, isPlaying=${mediaPlayer?.isPlaying})")
}
}
private fun cancelPositionUpdates() {
if (positionUpdateJob?.isActive == true) {
Timber.d("--> [Service] Cancelling active position update coroutine.")
positionUpdateJob?.cancel()
} else {
// Timber.d("--> [Service] No active position update coroutine to cancel.") // Optional: less verbose log
positionUpdateJob?.let { job ->
if (job.isActive) {
Timber.i("☢️ [POSITION UPDATES] Cancelling active update job")
job.cancel()
}
}
positionUpdateJob = null
}

@ -802,13 +802,13 @@ fun FullTextDisplayArea(
// Get the final index to highlight, preferring MP3 ID if available
val highlightIndex by remember(mp3HighlightedSentenceId, ttsSentenceIndex) {
derivedStateOf {
// Disabled MP3 highlighting for now as word tracking is not ready
// mp3HighlightedSentenceId?.toIntOrNull() // Convert MP3 String ID to Int
// ?: ttsSentenceIndex.takeIf { it != -1 } // Fallback to TTS index
// ?: -1 // No highlight
// Disabled MP3 highlighting for now as word tracking is not ready - RE-ENABLING
mp3HighlightedSentenceId?.toIntOrNull() // Convert MP3 String ID to Int
?: ttsSentenceIndex.takeIf { it != -1 } // Fallback to TTS index if MP3 ID is null
?: -1 // No highlight if both are invalid/unavailable
// Only use TTS index for highlighting
ttsSentenceIndex.takeIf { it != -1 } ?: -1
// Only use TTS index for highlighting - REMOVING
// ttsSentenceIndex.takeIf { it != -1 } ?: -1
}
}

@ -255,6 +255,9 @@ class MainViewModel(
// --- End Error Check ---
if (state.isPlaying) { // Check the boolean flag
// <<< ADDED LOGGING >>>
Timber.d(">>> Playback state isPlaying = true. Checking timings before starting tracker: ${_sentenceTimings.value.size} timings available. First few: ${_sentenceTimings.value.take(3)}")
Timber.d(">>> Calling startPlaybackTracking().")
startPlaybackTracking()
} else {
stopPlaybackTracking()
@ -481,6 +484,8 @@ class MainViewModel(
_fullDocumentText.value = loadedContent.fullText
_processingStatus.value = PdfProcessingStatus.READY_TO_PLAY // Ready state
Timber.i("Successfully loaded existing content for $fileName. Ready to play.")
// <<< ADDED LOGGING >>>
Timber.i(">>> Timings loaded from existing content: ${_sentenceTimings.value.size} timings. First few: ${_sentenceTimings.value.take(3)}")
// Optionally auto-play?
// handlePlayMp3()
// *** IMPORTANT: Do nothing further, content is loaded ***
@ -698,6 +703,8 @@ class MainViewModel(
_sentenceTimings.value = loadedTimings
_fullDocumentText.value = reconstructedText // Update full text based on timings
_processingStatus.value = PdfProcessingStatus.READY_TO_PLAY // Use correct Enum value
// <<< ADDED LOGGING >>>
Timber.i(">>> Timings loaded after generation: ${_sentenceTimings.value.size} timings. First few: ${_sentenceTimings.value.take(3)}")
} catch (e: Exception) {
Timber.e(e, "Error loading timings after generation from $jsonUri")
// Update state (thread-safe)
@ -924,6 +931,7 @@ class MainViewModel(
private fun startPlaybackTracking() {
stopPlaybackTracking() // Ensure only one tracking job runs
val currentTimings = _sentenceTimings.value
Timber.d(">>> startPlaybackTracking called. Timings empty? ${currentTimings.isEmpty()}")
if (currentTimings.isEmpty()) {
Timber.w("Cannot start playback tracking: Sentence timings are empty.")
return
@ -931,11 +939,61 @@ class MainViewModel(
Timber.d("Starting playback tracking job.")
playbackTrackingJob = viewModelScope.launch {
Timber.d(">>> Playback tracking Job STARTED.")
while (isActive) { // Loop while the job is active (and playback is PLAYING)
// Get position directly inside the loop to ensure it's fresh
updateHighlightForCurrentPosition(playbackPositionMs.value, currentTimings)
delay(TRACKING_INTERVAL_MS)
val currentPosition = playbackPositionMs.value
Timber.d(">>> Tracking Loop: currentPosition = $currentPosition ms")
updateHighlightForCurrentPosition(currentPosition, currentTimings)
// --- Calculate adaptive delay ---
val highlightedId = _highlightedSentenceId.value
val highlightedIndex = highlightedId?.toIntOrNull()
val delayMs = if (highlightedIndex != null && highlightedIndex >= 0 && highlightedIndex < currentTimings.size) {
val currentSentence = currentTimings[highlightedIndex]
val sentenceEndTime = currentSentence.startMs + currentSentence.durationMs
// Calculate time remaining in the current sentence
val timeRemainingInSentence = sentenceEndTime - currentPosition
// Look ahead to the next sentence's start time if not the last sentence
val nextSentenceStartTime = if (highlightedIndex + 1 < currentTimings.size) {
currentTimings[highlightedIndex + 1].startMs
} else {
null // No next sentence
}
// Time until the next sentence actually starts
val timeUntilNextSentenceStarts = nextSentenceStartTime?.let { it - currentPosition }
// Choose the smaller positive delay: either remaining time in current sentence
// or time until next sentence starts (if available).
// This aims to re-check *just before* or *just as* the next sentence begins.
val calculatedDelay = when {
timeUntilNextSentenceStarts != null && timeUntilNextSentenceStarts > 0 ->
minOf(timeRemainingInSentence, timeUntilNextSentenceStarts)
timeRemainingInSentence > 0 ->
timeRemainingInSentence
else ->
TRACKING_INTERVAL_MS // Fallback if remaining time is non-positive
}
// Ensure delay is positive and clamp to max interval
maxOf(1L, minOf(calculatedDelay, TRACKING_INTERVAL_MS))
} else {
// Fallback if no highlight or index is invalid
TRACKING_INTERVAL_MS
}
// --- End adaptive delay calculation ---
// <-- ADDED LOG
Timber.d(">>> Tracking Loop: calculated delay = $delayMs ms")
Timber.v("Playback tracking delay: $delayMs ms") // Verbose logging for delay
delay(delayMs)
}
Timber.d(">>> Playback tracking Job loop EXITED.")
}
// Log when the job completes or is cancelled
playbackTrackingJob?.invokeOnCompletion { cause ->
@ -958,6 +1016,8 @@ class MainViewModel(
// Helper function to find and update the highlighted sentence
// Can be called during tracking or explicitly on pause/seek
private fun updateHighlightForCurrentPosition(positionMs: Long, timings: List<SentenceTiming>? = null) {
// <-- ADDED LOG
Timber.d(">>> updateHighlightForCurrentPosition called with positionMs = $positionMs")
val currentTimings = timings ?: _sentenceTimings.value // Use provided timings or current state
if (currentTimings.isEmpty()) {
// Timber.v("Cannot update highlight: No timings available.") // Reduce log spam
@ -976,9 +1036,13 @@ class MainViewModel(
// Use index as the unique identifier (convert to String)
val newSentenceId = currentSentence?.index?.toString()
// <-- ADDED LOG
Timber.d(">>> updateHighlightForCurrentPosition found newSentenceId = $newSentenceId")
// Update the state flow only if the sentence ID has changed
if (_highlightedSentenceId.value != newSentenceId) {
// <-- ADDED LOG
Timber.d(">>> !!! Updating _highlightedSentenceId to: $newSentenceId")
Timber.d("Updating highlighted sentence: ID=${newSentenceId} at position ${positionMs}ms")
_highlightedSentenceId.value = newSentenceId
} else {
@ -1042,6 +1106,8 @@ class MainViewModel(
Timber.w("Timings loaded, but reconstructed text is empty. Check SentenceTiming model or JSON content.")
// Proceed? Or error out? For now, proceed but log.
}
// <<< ADDED LOGGING >>>
Timber.i(">>> Timings loaded from JSON pairing: ${_sentenceTimings.value.size} timings. First few: ${_sentenceTimings.value.take(3)}")
// Clear any previous TTS state
stopTTSUseCase()