Auto commit (MindMachine) Mon Apr 13 06:01:01 PM CDT 2026
This commit is contained in:
@@ -1,8 +1,16 @@
|
||||
package solutions.tretter.mindmachine
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
@@ -84,6 +92,7 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -592,12 +601,6 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
|
||||
brightnessPercent = ui.settings.immersiveBrightnessPercent,
|
||||
)
|
||||
|
||||
val flashFrame by rememberSplitFlashFrame(
|
||||
isRunning = ui.runtimeState == RuntimeState.RUNNING,
|
||||
flashOnMs = ui.runtimeParams.flashOnMs,
|
||||
flashOffMs = ui.runtimeParams.flashOffMs,
|
||||
)
|
||||
|
||||
val progress = sessionProgressFraction(
|
||||
durationSec = ui.timeline.durationSec,
|
||||
remainingSec = ui.remainingSec,
|
||||
@@ -619,20 +622,19 @@ fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry:
|
||||
}
|
||||
) {
|
||||
if (isVisualMode) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(flashFrame.left.toComposeColor())
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(flashFrame.right.toComposeColor())
|
||||
)
|
||||
}
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
SplitFlashRenderView(ctx).apply {
|
||||
setRunning(ui.runtimeState == RuntimeState.RUNNING)
|
||||
updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
|
||||
view.setRunning(ui.runtimeState == RuntimeState.RUNNING)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (ui.runtimeState == RuntimeState.COUNTDOWN) {
|
||||
@@ -792,6 +794,150 @@ private fun ApplyImmersiveMode(enabled: Boolean, revealSignal: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
private class SplitFlashRenderView(context: Context) : View(context) {
|
||||
private val timingLogTag = "MindMachineTiming"
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val leftPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val rightPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private var isRunning = false
|
||||
private var phase = 1 // start black
|
||||
private var rawOnMs = 167L
|
||||
private var rawOffMs = 167L
|
||||
private var onMs = 167L
|
||||
private var offMs = 167L
|
||||
private var started = false
|
||||
private var startUptimeMs = 0L
|
||||
private var nextToggleUptimeMs = 0L
|
||||
private var toggleCount = 0L
|
||||
private var lateToggleCount = 0L
|
||||
private var maxLatenessMs = 0L
|
||||
|
||||
private val toggleRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (!isRunning) return
|
||||
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val latenessMs = (now - nextToggleUptimeMs).coerceAtLeast(0L)
|
||||
val previousPhase = phase
|
||||
|
||||
if (latenessMs > 0L) {
|
||||
lateToggleCount += 1
|
||||
maxLatenessMs = maxOf(maxLatenessMs, latenessMs)
|
||||
}
|
||||
|
||||
phase = (phase + 1) % 4
|
||||
toggleCount += 1
|
||||
invalidate()
|
||||
|
||||
val nextDuration = if (phase == 0 || phase == 2) onMs else offMs
|
||||
nextToggleUptimeMs += nextDuration
|
||||
|
||||
val shouldLogToggle = toggleCount <= 20 || latenessMs > framePeriodMs() || toggleCount % 60L == 0L
|
||||
if (shouldLogToggle) {
|
||||
Log.d(
|
||||
timingLogTag,
|
||||
"toggle#$toggleCount prev=${phaseName(previousPhase)} next=${phaseName(phase)} " +
|
||||
"scheduledMs=${nextToggleUptimeMs - startUptimeMs - nextDuration} " +
|
||||
"actualMs=${now - startUptimeMs} latenessMs=$latenessMs " +
|
||||
"lateCount=$lateToggleCount maxLateMs=$maxLatenessMs"
|
||||
)
|
||||
}
|
||||
|
||||
scheduleNext()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateIntervals(flashOnMs: Int, flashOffMs: Int) {
|
||||
rawOnMs = flashOnMs.coerceIn(50, 2000).toLong()
|
||||
rawOffMs = flashOffMs.coerceIn(50, 2000).toLong()
|
||||
val frameMs = framePeriodMs().coerceAtLeast(1L)
|
||||
onMs = snapToFrameMs(rawOnMs, frameMs)
|
||||
offMs = snapToFrameMs(rawOffMs, frameMs)
|
||||
|
||||
Log.d(
|
||||
timingLogTag,
|
||||
"config onMs=$rawOnMs offMs=$rawOffMs refreshHz=${"%.2f".format(refreshRate())} frameMs=$frameMs snappedOnMs=$onMs snappedOffMs=$offMs"
|
||||
)
|
||||
|
||||
if (isRunning && started) {
|
||||
scheduleFromNow()
|
||||
}
|
||||
}
|
||||
|
||||
fun setRunning(running: Boolean) {
|
||||
if (isRunning == running) return
|
||||
isRunning = running
|
||||
handler.removeCallbacks(toggleRunnable)
|
||||
|
||||
if (!running) {
|
||||
started = false
|
||||
phase = 1
|
||||
invalidate()
|
||||
return
|
||||
}
|
||||
|
||||
started = true
|
||||
phase = 0
|
||||
startUptimeMs = SystemClock.uptimeMillis()
|
||||
nextToggleUptimeMs = startUptimeMs + onMs
|
||||
toggleCount = 0L
|
||||
lateToggleCount = 0L
|
||||
maxLatenessMs = 0L
|
||||
Log.d(
|
||||
timingLogTag,
|
||||
"session-start phase=${phaseName(phase)} refreshHz=${"%.2f".format(refreshRate())} frameMs=${framePeriodMs()} nowMs=$startUptimeMs"
|
||||
)
|
||||
invalidate()
|
||||
scheduleNext()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
handler.removeCallbacks(toggleRunnable)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
val mid = width / 2f
|
||||
val (leftColor, rightColor) = colorsForPhase(phase)
|
||||
leftPaint.color = leftColor
|
||||
rightPaint.color = rightColor
|
||||
canvas.drawRect(0f, 0f, mid, height.toFloat(), leftPaint)
|
||||
canvas.drawRect(mid, 0f, width.toFloat(), height.toFloat(), rightPaint)
|
||||
}
|
||||
|
||||
private fun scheduleNext() {
|
||||
handler.removeCallbacks(toggleRunnable)
|
||||
handler.postAtTime(toggleRunnable, nextToggleUptimeMs)
|
||||
}
|
||||
|
||||
private fun scheduleFromNow() {
|
||||
val now = SystemClock.uptimeMillis()
|
||||
nextToggleUptimeMs = now + if (phase == 0 || phase == 2) onMs else offMs
|
||||
scheduleNext()
|
||||
}
|
||||
|
||||
private fun colorsForPhase(phase: Int): Pair<Int, Int> = when (phase) {
|
||||
0 -> Pair(android.graphics.Color.RED, android.graphics.Color.GREEN)
|
||||
2 -> Pair(android.graphics.Color.GREEN, android.graphics.Color.RED)
|
||||
else -> Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK)
|
||||
}
|
||||
|
||||
private fun phaseName(phase: Int): String = when (phase) {
|
||||
0 -> "RED_GREEN"
|
||||
1 -> "BLACK"
|
||||
2 -> "GREEN_RED"
|
||||
else -> "BLACK"
|
||||
}
|
||||
|
||||
private fun refreshRate(): Float = display?.refreshRate?.takeIf { it > 0f } ?: 60f
|
||||
|
||||
private fun framePeriodMs(): Long = (1000f / refreshRate()).roundToLong().coerceAtLeast(1L)
|
||||
|
||||
private fun snapToFrameMs(valueMs: Long, frameMs: Long): Long = ((valueMs + frameMs / 2) / frameMs).coerceAtLeast(1L) * frameMs
|
||||
}
|
||||
|
||||
private fun FlashColor.toComposeColor(): Color = when (this) {
|
||||
FlashColor.RED -> Color.Red
|
||||
FlashColor.GREEN -> Color.Green
|
||||
@@ -815,6 +961,8 @@ private fun rememberSplitFlashFrame(
|
||||
(1_000_000_000.0 / refreshRateHz.coerceAtLeast(1f)).roundToLong().coerceAtLeast(1L)
|
||||
}
|
||||
|
||||
val timingLogTag = "MindMachineTiming"
|
||||
|
||||
return produceState(initialValue = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK), isRunning, framePeriodNanos) {
|
||||
if (!isRunning) {
|
||||
value = SplitFlashFrame(FlashColor.BLACK, FlashColor.BLACK)
|
||||
@@ -832,6 +980,20 @@ private fun rememberSplitFlashFrame(
|
||||
var nextToggleNanos = 0L
|
||||
var onNanos = 0L
|
||||
var offNanos = 0L
|
||||
var lastLoggedFlashOnMs = -1
|
||||
var lastLoggedFlashOffMs = -1
|
||||
var toggleCount = 0L
|
||||
var lateToggleCount = 0L
|
||||
var maxLatenessNanos = 0L
|
||||
var lastToggleAtNanos = 0L
|
||||
var sessionStartNanos = 0L
|
||||
|
||||
fun phaseName(p: Int): String = when (p) {
|
||||
0 -> "RED_GREEN"
|
||||
1 -> "BLACK"
|
||||
2 -> "GREEN_RED"
|
||||
else -> "BLACK"
|
||||
}
|
||||
|
||||
fun frameForPhase(p: Int): SplitFlashFrame = when (p) {
|
||||
0 -> SplitFlashFrame(left = FlashColor.RED, right = FlashColor.GREEN)
|
||||
@@ -849,6 +1011,19 @@ private fun rememberSplitFlashFrame(
|
||||
|
||||
onNanos = onFrames * framePeriodNanos
|
||||
offNanos = offFrames * framePeriodNanos
|
||||
|
||||
val currentFlashOnMs = latestFlashOnMs.value.coerceIn(50, 2000)
|
||||
val currentFlashOffMs = latestFlashOffMs.value.coerceIn(50, 2000)
|
||||
if (currentFlashOnMs != lastLoggedFlashOnMs || currentFlashOffMs != lastLoggedFlashOffMs) {
|
||||
Log.d(
|
||||
timingLogTag,
|
||||
"config onMs=$currentFlashOnMs offMs=$currentFlashOffMs refreshHz=${"%.2f".format(refreshRateHz)} " +
|
||||
"frameMs=${"%.3f".format(framePeriodNanos / 1_000_000.0)} snappedOnMs=${"%.3f".format(onNanos / 1_000_000.0)} " +
|
||||
"snappedOffMs=${"%.3f".format(offNanos / 1_000_000.0)}"
|
||||
)
|
||||
lastLoggedFlashOnMs = currentFlashOnMs
|
||||
lastLoggedFlashOffMs = currentFlashOffMs
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
@@ -859,6 +1034,13 @@ private fun rememberSplitFlashFrame(
|
||||
nextToggleNanos = now + onNanos
|
||||
value = frameForPhase(phase)
|
||||
started = true
|
||||
sessionStartNanos = now
|
||||
lastToggleAtNanos = now
|
||||
Log.d(
|
||||
timingLogTag,
|
||||
"session-start phase=${phaseName(phase)} refreshHz=${"%.2f".format(refreshRateHz)} " +
|
||||
"frameMs=${"%.3f".format(framePeriodNanos / 1_000_000.0)} nowNs=$now"
|
||||
)
|
||||
return@withFrameNanos
|
||||
}
|
||||
|
||||
@@ -866,9 +1048,35 @@ private fun rememberSplitFlashFrame(
|
||||
|
||||
// Catch up if we missed one or more phase boundaries due to frame delays.
|
||||
while (now >= nextToggleNanos) {
|
||||
val scheduledToggleNanos = nextToggleNanos
|
||||
val previousPhase = phase
|
||||
phase = (phase + 1) % 4
|
||||
val latenessNanos = now - scheduledToggleNanos
|
||||
val actualDeltaNanos = now - lastToggleAtNanos
|
||||
val dur = if (phase == 0 || phase == 2) onNanos else offNanos
|
||||
nextToggleNanos += dur
|
||||
toggleCount += 1
|
||||
lastToggleAtNanos = now
|
||||
|
||||
if (latenessNanos > 0L) {
|
||||
lateToggleCount += 1
|
||||
if (latenessNanos > maxLatenessNanos) {
|
||||
maxLatenessNanos = latenessNanos
|
||||
}
|
||||
}
|
||||
|
||||
val shouldLogToggle = toggleCount <= 20 || latenessNanos > framePeriodNanos || toggleCount % 60L == 0L
|
||||
if (shouldLogToggle) {
|
||||
Log.d(
|
||||
timingLogTag,
|
||||
"toggle#$toggleCount prev=${phaseName(previousPhase)} next=${phaseName(phase)} " +
|
||||
"scheduledMs=${"%.3f".format((scheduledToggleNanos - sessionStartNanos) / 1_000_000.0)} " +
|
||||
"actualMs=${"%.3f".format((now - sessionStartNanos) / 1_000_000.0)} " +
|
||||
"actualDeltaMs=${"%.3f".format(actualDeltaNanos / 1_000_000.0)} " +
|
||||
"latenessMs=${"%.3f".format(latenessNanos / 1_000_000.0)} " +
|
||||
"lateCount=$lateToggleCount maxLateMs=${"%.3f".format(maxLatenessNanos / 1_000_000.0)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
value = frameForPhase(phase)
|
||||
|
||||
Reference in New Issue
Block a user