Исправить анимацию waveform после перемотки ГС без отката к началу

This commit is contained in:
2026-04-17 14:19:32 +05:00
parent 7521b9a11b
commit edd0e73de9

View File

@@ -215,10 +215,14 @@ data class VoiceQueueEntry(
object VoicePlaybackCoordinator {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val speedSteps = listOf(1f, 1.5f, 2f)
private const val SEEK_STALE_SAMPLE_WINDOW_MS = 900L
private const val SEEK_SETTLE_TOLERANCE_MS = 180
private var player: MediaPlayer? = null
private var currentAttachmentId: String? = null
private var appContext: Context? = null
private var progressJob: Job? = null
private var pendingSeekTargetMs: Int? = null
private var pendingSeekExpiresAtMs: Long = 0L
private val queueEntries = LinkedHashMap<String, VoiceQueueEntry>()
private val _playingAttachmentId = MutableStateFlow<String?>(null)
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
@@ -246,6 +250,29 @@ object VoicePlaybackCoordinator {
queueEntries.remove(attachmentId)
}
private fun setPendingSeek(targetMs: Int) {
pendingSeekTargetMs = targetMs.coerceAtLeast(0)
pendingSeekExpiresAtMs = SystemClock.elapsedRealtime() + SEEK_STALE_SAMPLE_WINDOW_MS
}
private fun clearPendingSeek() {
pendingSeekTargetMs = null
pendingSeekExpiresAtMs = 0L
}
private fun resolvePositionForUi(sampledPositionMs: Int): Int {
val pendingTarget = pendingSeekTargetMs ?: return sampledPositionMs
val now = SystemClock.elapsedRealtime()
val settled = kotlin.math.abs(sampledPositionMs - pendingTarget) <= SEEK_SETTLE_TOLERANCE_MS
val expired = now >= pendingSeekExpiresAtMs
return if (settled || expired) {
clearPendingSeek()
sampledPositionMs
} else {
pendingTarget
}
}
fun toggle(
attachmentId: String,
sourceFile: File,
@@ -269,6 +296,7 @@ object VoicePlaybackCoordinator {
}
stop()
clearPendingSeek()
val mediaPlayer = MediaPlayer()
try {
mediaPlayer.setAudioAttributes(
@@ -322,6 +350,7 @@ object VoicePlaybackCoordinator {
}
if (currentAttachmentId == attachmentId && player != null) {
clearPendingSeek()
val active = player ?: return
_playingAttachmentId.value = attachmentId
_playingDialogKey.value = dialogKey.trim().ifBlank { null }
@@ -333,6 +362,7 @@ object VoicePlaybackCoordinator {
}
stop()
clearPendingSeek()
val mediaPlayer = MediaPlayer()
try {
mediaPlayer.setAudioAttributes(
@@ -364,6 +394,7 @@ object VoicePlaybackCoordinator {
}
fun pause() {
clearPendingSeek()
val active = player ?: return
runCatching {
if (active.isPlaying) active.pause()
@@ -403,7 +434,7 @@ object VoicePlaybackCoordinator {
val duration = runCatching { active.duration }.getOrDefault(_durationMs.value).coerceAtLeast(0)
if (duration <= 0) return
val clampedPosition = positionMs.coerceIn(0, duration)
runCatching {
val seekResult = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
active.seekTo(clampedPosition.toLong(), MediaPlayer.SEEK_CLOSEST_SYNC)
} else {
@@ -411,6 +442,11 @@ object VoicePlaybackCoordinator {
active.seekTo(clampedPosition)
}
}
if (seekResult.isSuccess) {
setPendingSeek(clampedPosition)
} else {
clearPendingSeek()
}
if (keepPaused) {
// Some devices auto-resume after seek; for paused scrub we force pause every time.
runCatching {
@@ -461,7 +497,8 @@ object VoicePlaybackCoordinator {
scope.launch {
while (isActive && currentAttachmentId == attachmentId) {
val active = player ?: break
_positionMs.value = active.currentPosition.coerceAtLeast(0)
val sampledPosition = active.currentPosition.coerceAtLeast(0)
_positionMs.value = resolvePositionForUi(sampledPosition)
_durationMs.value = active.duration.coerceAtLeast(0)
if (!active.isPlaying) break
delay(120)
@@ -471,6 +508,7 @@ object VoicePlaybackCoordinator {
}
fun stop() {
clearPendingSeek()
val active = player
player = null
currentAttachmentId = null
@@ -2543,17 +2581,34 @@ private fun VoiceAttachment(
var waveformWidthPx by remember(attachment.id) { mutableFloatStateOf(0f) }
var isWaveformTouchLocked by remember(attachment.id) { mutableStateOf(false) }
var suppressMainActionUntilMs by remember(attachment.id) { mutableLongStateOf(0L) }
val smoothProgress by
animateFloatAsState(
targetValue = progress,
val progressAnimator = remember(attachment.id) { Animatable(0f) }
LaunchedEffect(attachment.id, isActiveTrack) {
if (!isActiveTrack) {
progressAnimator.snapTo(0f)
}
}
LaunchedEffect(progress, isPlaying, isScrubbing, isActiveTrack) {
if (!isActiveTrack || isScrubbing) return@LaunchedEffect
val target = progress.coerceIn(0f, 1f)
val delta = kotlin.math.abs(target - progressAnimator.value)
// Для seek/резких скачков сразу фиксируем позицию без "пробега" от старого значения.
if (delta >= 0.12f) {
progressAnimator.snapTo(target)
} else {
progressAnimator.animateTo(
targetValue = target,
animationSpec =
if (isPlaying) {
tween(durationMillis = 140, easing = LinearEasing)
tween(durationMillis = 120, easing = LinearEasing)
} else {
tween(durationMillis = 180, easing = FastOutSlowInEasing)
},
label = "voice_wave_progress"
tween(durationMillis = 150, easing = FastOutSlowInEasing)
}
)
}
}
val smoothProgress = progressAnimator.value
val displayProgress = if (isScrubbing) scrubProgress else smoothProgress
val liveWaveLevel =
remember(isPlaying, progress, waves) {
@@ -3033,6 +3088,9 @@ private fun VoiceAttachment(
if (isDraggingWaveform) {
val releaseProgress = progressFromWaveX(lastTouchX)
scrubProgress = releaseProgress
scope.launch {
progressAnimator.snapTo(releaseProgress)
}
VoicePlaybackCoordinator.seekToProgress(
releaseProgress,
keepPaused = !wasPlayingOnTouchDown