Merge branch 'dev'
This commit is contained in:
@@ -215,10 +215,14 @@ data class VoiceQueueEntry(
|
|||||||
object VoicePlaybackCoordinator {
|
object VoicePlaybackCoordinator {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private val speedSteps = listOf(1f, 1.5f, 2f)
|
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 player: MediaPlayer? = null
|
||||||
private var currentAttachmentId: String? = null
|
private var currentAttachmentId: String? = null
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
private var progressJob: Job? = null
|
private var progressJob: Job? = null
|
||||||
|
private var pendingSeekTargetMs: Int? = null
|
||||||
|
private var pendingSeekExpiresAtMs: Long = 0L
|
||||||
private val queueEntries = LinkedHashMap<String, VoiceQueueEntry>()
|
private val queueEntries = LinkedHashMap<String, VoiceQueueEntry>()
|
||||||
private val _playingAttachmentId = MutableStateFlow<String?>(null)
|
private val _playingAttachmentId = MutableStateFlow<String?>(null)
|
||||||
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
|
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
|
||||||
@@ -246,6 +250,29 @@ object VoicePlaybackCoordinator {
|
|||||||
queueEntries.remove(attachmentId)
|
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(
|
fun toggle(
|
||||||
attachmentId: String,
|
attachmentId: String,
|
||||||
sourceFile: File,
|
sourceFile: File,
|
||||||
@@ -269,6 +296,7 @@ object VoicePlaybackCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop()
|
stop()
|
||||||
|
clearPendingSeek()
|
||||||
val mediaPlayer = MediaPlayer()
|
val mediaPlayer = MediaPlayer()
|
||||||
try {
|
try {
|
||||||
mediaPlayer.setAudioAttributes(
|
mediaPlayer.setAudioAttributes(
|
||||||
@@ -322,6 +350,7 @@ object VoicePlaybackCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentAttachmentId == attachmentId && player != null) {
|
if (currentAttachmentId == attachmentId && player != null) {
|
||||||
|
clearPendingSeek()
|
||||||
val active = player ?: return
|
val active = player ?: return
|
||||||
_playingAttachmentId.value = attachmentId
|
_playingAttachmentId.value = attachmentId
|
||||||
_playingDialogKey.value = dialogKey.trim().ifBlank { null }
|
_playingDialogKey.value = dialogKey.trim().ifBlank { null }
|
||||||
@@ -333,6 +362,7 @@ object VoicePlaybackCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop()
|
stop()
|
||||||
|
clearPendingSeek()
|
||||||
val mediaPlayer = MediaPlayer()
|
val mediaPlayer = MediaPlayer()
|
||||||
try {
|
try {
|
||||||
mediaPlayer.setAudioAttributes(
|
mediaPlayer.setAudioAttributes(
|
||||||
@@ -364,6 +394,7 @@ object VoicePlaybackCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
|
clearPendingSeek()
|
||||||
val active = player ?: return
|
val active = player ?: return
|
||||||
runCatching {
|
runCatching {
|
||||||
if (active.isPlaying) active.pause()
|
if (active.isPlaying) active.pause()
|
||||||
@@ -403,7 +434,7 @@ object VoicePlaybackCoordinator {
|
|||||||
val duration = runCatching { active.duration }.getOrDefault(_durationMs.value).coerceAtLeast(0)
|
val duration = runCatching { active.duration }.getOrDefault(_durationMs.value).coerceAtLeast(0)
|
||||||
if (duration <= 0) return
|
if (duration <= 0) return
|
||||||
val clampedPosition = positionMs.coerceIn(0, duration)
|
val clampedPosition = positionMs.coerceIn(0, duration)
|
||||||
runCatching {
|
val seekResult = runCatching {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
active.seekTo(clampedPosition.toLong(), MediaPlayer.SEEK_CLOSEST_SYNC)
|
active.seekTo(clampedPosition.toLong(), MediaPlayer.SEEK_CLOSEST_SYNC)
|
||||||
} else {
|
} else {
|
||||||
@@ -411,6 +442,11 @@ object VoicePlaybackCoordinator {
|
|||||||
active.seekTo(clampedPosition)
|
active.seekTo(clampedPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (seekResult.isSuccess) {
|
||||||
|
setPendingSeek(clampedPosition)
|
||||||
|
} else {
|
||||||
|
clearPendingSeek()
|
||||||
|
}
|
||||||
if (keepPaused) {
|
if (keepPaused) {
|
||||||
// Some devices auto-resume after seek; for paused scrub we force pause every time.
|
// Some devices auto-resume after seek; for paused scrub we force pause every time.
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -461,7 +497,8 @@ object VoicePlaybackCoordinator {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
while (isActive && currentAttachmentId == attachmentId) {
|
while (isActive && currentAttachmentId == attachmentId) {
|
||||||
val active = player ?: break
|
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)
|
_durationMs.value = active.duration.coerceAtLeast(0)
|
||||||
if (!active.isPlaying) break
|
if (!active.isPlaying) break
|
||||||
delay(120)
|
delay(120)
|
||||||
@@ -471,6 +508,7 @@ object VoicePlaybackCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
clearPendingSeek()
|
||||||
val active = player
|
val active = player
|
||||||
player = null
|
player = null
|
||||||
currentAttachmentId = null
|
currentAttachmentId = null
|
||||||
@@ -2543,17 +2581,34 @@ private fun VoiceAttachment(
|
|||||||
var waveformWidthPx by remember(attachment.id) { mutableFloatStateOf(0f) }
|
var waveformWidthPx by remember(attachment.id) { mutableFloatStateOf(0f) }
|
||||||
var isWaveformTouchLocked by remember(attachment.id) { mutableStateOf(false) }
|
var isWaveformTouchLocked by remember(attachment.id) { mutableStateOf(false) }
|
||||||
var suppressMainActionUntilMs by remember(attachment.id) { mutableLongStateOf(0L) }
|
var suppressMainActionUntilMs by remember(attachment.id) { mutableLongStateOf(0L) }
|
||||||
val smoothProgress by
|
val progressAnimator = remember(attachment.id) { Animatable(0f) }
|
||||||
animateFloatAsState(
|
LaunchedEffect(attachment.id, isActiveTrack) {
|
||||||
targetValue = progress,
|
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 =
|
animationSpec =
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
tween(durationMillis = 140, easing = LinearEasing)
|
tween(durationMillis = 120, easing = LinearEasing)
|
||||||
} else {
|
} else {
|
||||||
tween(durationMillis = 180, easing = FastOutSlowInEasing)
|
tween(durationMillis = 150, easing = FastOutSlowInEasing)
|
||||||
},
|
}
|
||||||
label = "voice_wave_progress"
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val smoothProgress = progressAnimator.value
|
||||||
val displayProgress = if (isScrubbing) scrubProgress else smoothProgress
|
val displayProgress = if (isScrubbing) scrubProgress else smoothProgress
|
||||||
val liveWaveLevel =
|
val liveWaveLevel =
|
||||||
remember(isPlaying, progress, waves) {
|
remember(isPlaying, progress, waves) {
|
||||||
@@ -3033,6 +3088,9 @@ private fun VoiceAttachment(
|
|||||||
if (isDraggingWaveform) {
|
if (isDraggingWaveform) {
|
||||||
val releaseProgress = progressFromWaveX(lastTouchX)
|
val releaseProgress = progressFromWaveX(lastTouchX)
|
||||||
scrubProgress = releaseProgress
|
scrubProgress = releaseProgress
|
||||||
|
scope.launch {
|
||||||
|
progressAnimator.snapTo(releaseProgress)
|
||||||
|
}
|
||||||
VoicePlaybackCoordinator.seekToProgress(
|
VoicePlaybackCoordinator.seekToProgress(
|
||||||
releaseProgress,
|
releaseProgress,
|
||||||
keepPaused = !wasPlayingOnTouchDown
|
keepPaused = !wasPlayingOnTouchDown
|
||||||
|
|||||||
Reference in New Issue
Block a user