fix: MP3 text highlighting now updates during playback
This commit is contained in:
parent
f092e3937a
commit
22f38366d9
app/src/main/java/com/voca/app
data/repository
service
ui
viewmodel
@ -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()
|
||||
|
Loading…
x
Reference in New Issue
Block a user