From de6e9d6230d6dd378330440b209dc26a40f64354 Mon Sep 17 00:00:00 2001 From: Tretzi Date: Mon, 13 Apr 2026 18:01:01 -0500 Subject: [PATCH] Auto commit (MindMachine) Mon Apr 13 06:01:01 PM CDT 2026 --- .../java/com/mindmachine/mvp/MainActivity.kt | 248 ++++++++++++++++-- 1 file changed, 228 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt index 581a15e..55be8bd 100644 --- a/app/src/main/java/com/mindmachine/mvp/MainActivity.kt +++ b/app/src/main/java/com/mindmachine/mvp/MainActivity.kt @@ -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 = 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)