1370 lines
56 KiB
Kotlin
1370 lines
56 KiB
Kotlin
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
|
||
import androidx.activity.compose.setContent
|
||
import androidx.activity.viewModels
|
||
import androidx.compose.foundation.background
|
||
import solutions.tretter.mindmachine.session.DurationSliderCard
|
||
import solutions.tretter.mindmachine.session.FlashSequenceEditor
|
||
import solutions.tretter.mindmachine.session.SetupTimelineEditor
|
||
import androidx.compose.foundation.clickable
|
||
import androidx.compose.foundation.gestures.detectTapGestures
|
||
import androidx.compose.foundation.layout.Arrangement
|
||
import androidx.compose.foundation.layout.Box
|
||
import androidx.compose.foundation.layout.Column
|
||
import androidx.compose.foundation.layout.ColumnScope
|
||
import androidx.compose.foundation.layout.Row
|
||
import androidx.compose.foundation.layout.Spacer
|
||
import androidx.compose.foundation.layout.WindowInsets
|
||
import androidx.compose.foundation.layout.fillMaxHeight
|
||
import androidx.compose.foundation.layout.fillMaxSize
|
||
import androidx.compose.foundation.layout.fillMaxWidth
|
||
import androidx.compose.foundation.layout.height
|
||
import androidx.compose.foundation.layout.width
|
||
import androidx.compose.foundation.layout.navigationBars
|
||
import androidx.compose.foundation.layout.padding
|
||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||
import androidx.compose.foundation.layout.size
|
||
import androidx.compose.foundation.layout.statusBars
|
||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||
import androidx.compose.foundation.rememberScrollState
|
||
import androidx.compose.foundation.verticalScroll
|
||
import androidx.compose.foundation.lazy.LazyColumn
|
||
import androidx.compose.foundation.lazy.items
|
||
import androidx.compose.foundation.Image
|
||
import androidx.compose.foundation.Image
|
||
import androidx.compose.material.icons.Icons
|
||
import androidx.compose.material.icons.filled.Add
|
||
import androidx.compose.material.icons.filled.Delete
|
||
import androidx.compose.material.icons.filled.PlayArrow
|
||
import androidx.compose.material.icons.filled.Settings
|
||
import androidx.compose.ui.res.painterResource
|
||
import androidx.compose.material3.AlertDialog
|
||
import androidx.compose.material3.Button
|
||
import androidx.compose.material3.Card
|
||
import androidx.compose.material3.CardDefaults
|
||
import androidx.compose.material3.Checkbox
|
||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||
import androidx.compose.material3.HorizontalDivider
|
||
import androidx.compose.material3.Icon
|
||
import androidx.compose.material3.IconButton
|
||
import androidx.compose.material3.MaterialTheme
|
||
import androidx.compose.material3.LinearProgressIndicator
|
||
import androidx.compose.material3.OutlinedButton
|
||
import androidx.compose.material3.SegmentedButton
|
||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||
import androidx.compose.material3.Slider
|
||
import androidx.compose.material3.Text
|
||
import androidx.compose.material3.TextButton
|
||
import androidx.compose.material3.TextField
|
||
import androidx.compose.material3.TopAppBar
|
||
import androidx.compose.material3.darkColorScheme
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.runtime.DisposableEffect
|
||
import androidx.compose.runtime.LaunchedEffect
|
||
import androidx.compose.runtime.getValue
|
||
import androidx.compose.runtime.mutableIntStateOf
|
||
import androidx.compose.runtime.mutableStateOf
|
||
import androidx.compose.runtime.produceState
|
||
import androidx.compose.runtime.remember
|
||
import androidx.compose.runtime.rememberUpdatedState
|
||
import androidx.compose.runtime.setValue
|
||
import androidx.compose.runtime.withFrameNanos
|
||
import androidx.compose.ui.Alignment
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.graphics.Color
|
||
import androidx.compose.ui.input.pointer.pointerInput
|
||
import androidx.compose.ui.platform.LocalContext
|
||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||
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
|
||
import androidx.core.view.WindowInsetsControllerCompat
|
||
import androidx.lifecycle.Lifecycle
|
||
import androidx.lifecycle.LifecycleEventObserver
|
||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||
import androidx.lifecycle.lifecycleScope
|
||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||
import androidx.navigation.NavType
|
||
import androidx.navigation.compose.NavHost
|
||
import androidx.navigation.compose.composable
|
||
import androidx.navigation.compose.rememberNavController
|
||
import androidx.navigation.navArgument
|
||
import com.google.android.gms.ads.MobileAds
|
||
import solutions.tretter.mindmachine.audio.BinauralAudioEngine
|
||
import solutions.tretter.mindmachine.audio.HeadsetMonitor
|
||
import solutions.tretter.mindmachine.ads.AdBanner
|
||
import solutions.tretter.mindmachine.ads.rememberInterstitialController
|
||
import solutions.tretter.mindmachine.data.SettingsRepository
|
||
import solutions.tretter.mindmachine.data.UserProgramRepository
|
||
import solutions.tretter.mindmachine.domain.RuntimeState
|
||
import solutions.tretter.mindmachine.session.FlashColor
|
||
import solutions.tretter.mindmachine.session.MainViewModel
|
||
import solutions.tretter.mindmachine.session.SplitFlashFrame
|
||
import kotlinx.coroutines.launch
|
||
import solutions.tretter.mindmachine.session.flashIntervalMsToSeconds
|
||
import solutions.tretter.mindmachine.session.flashIntervalSecondsToMs
|
||
import solutions.tretter.mindmachine.session.formatDuration
|
||
import solutions.tretter.mindmachine.session.sessionProgressFraction
|
||
import solutions.tretter.mindmachine.session.shouldShowActiveControlsByDefault
|
||
import solutions.tretter.mindmachine.session.shouldUseImmersiveFullscreen
|
||
import kotlinx.coroutines.delay
|
||
import kotlin.math.roundToInt
|
||
import kotlin.math.roundToLong
|
||
import java.util.Locale
|
||
|
||
|
||
private const val SESSION_ENTRY_HOME = "home"
|
||
private const val SESSION_ENTRY_SETUP = "setup"
|
||
|
||
private val MindMachineColors = darkColorScheme(
|
||
primary = Color(0xFF8AB4FF),
|
||
onPrimary = Color(0xFF001B3D),
|
||
primaryContainer = Color(0xFF004689),
|
||
onPrimaryContainer = Color(0xFFD7E3FF),
|
||
secondary = Color(0xFFFFD166),
|
||
onSecondary = Color(0xFF2E2300),
|
||
background = Color(0xFF05070D),
|
||
onBackground = Color(0xFFF3F6FF),
|
||
surface = Color(0xFF121621),
|
||
onSurface = Color(0xFFF3F6FF),
|
||
error = Color(0xFFFF6B6B),
|
||
onError = Color(0xFF3A0002)
|
||
)
|
||
|
||
class MainActivity : ComponentActivity() {
|
||
private val settingsRepo by lazy { SettingsRepository(applicationContext) }
|
||
private val billingManager by lazy {
|
||
solutions.tretter.mindmachine.billing.BillingManager(
|
||
applicationContext,
|
||
onNoAdsEntitlementChanged = { hasNoAds ->
|
||
lifecycleScope.launch { settingsRepo.updateNoAds(hasNoAds) }
|
||
},
|
||
)
|
||
}
|
||
|
||
private val vm: MainViewModel by viewModels {
|
||
MainViewModel.Factory(
|
||
settingsRepo,
|
||
UserProgramRepository(applicationContext),
|
||
HeadsetMonitor(applicationContext),
|
||
BinauralAudioEngine(),
|
||
billingManager,
|
||
)
|
||
}
|
||
|
||
override fun onCreate(savedInstanceState: Bundle?) {
|
||
super.onCreate(savedInstanceState)
|
||
MobileAds.initialize(this) {}
|
||
setContent {
|
||
MaterialTheme(colorScheme = MindMachineColors) {
|
||
App(vm, billingManager)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@OptIn(ExperimentalMaterial3Api::class)
|
||
@Composable
|
||
fun App(vm: MainViewModel = viewModel(), billingManager: solutions.tretter.mindmachine.billing.BillingManager) {
|
||
val nav = rememberNavController()
|
||
val ui by vm.ui.collectAsStateWithLifecycle()
|
||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||
val activity = LocalContext.current as? Activity
|
||
|
||
DisposableEffect(lifecycle, activity, ui.runtimeState) {
|
||
val observer = LifecycleEventObserver { _, event ->
|
||
val appActuallyBackgrounded = activity?.isChangingConfigurations != true
|
||
if (
|
||
event == Lifecycle.Event.ON_STOP &&
|
||
ui.runtimeState == RuntimeState.RUNNING &&
|
||
appActuallyBackgrounded
|
||
) {
|
||
vm.pause("Session paused because app moved to background.")
|
||
}
|
||
}
|
||
lifecycle.addObserver(observer)
|
||
onDispose { lifecycle.removeObserver(observer) }
|
||
}
|
||
|
||
if (!ui.settingsInitialized) {
|
||
Box(
|
||
modifier = Modifier
|
||
.fillMaxSize()
|
||
.background(MaterialTheme.colorScheme.background)
|
||
)
|
||
return
|
||
}
|
||
|
||
val startRoute = if (ui.settings.safetyAcknowledged) "home" else "safety"
|
||
NavHost(navController = nav, startDestination = startRoute, modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
|
||
composable("safety") {
|
||
SafetyScreen(
|
||
onAck = {
|
||
vm.acknowledgeSafety()
|
||
nav.navigate("home") { popUpTo(0) }
|
||
},
|
||
onHolder = { nav.navigate("holder") }
|
||
)
|
||
}
|
||
composable("home") {
|
||
var confirmDeleteProgramId by remember { mutableStateOf<String?>(null) }
|
||
var confirmDeleteProgramName by remember { mutableStateOf("") }
|
||
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds)
|
||
|
||
Column(
|
||
Modifier
|
||
.fillMaxSize()
|
||
.safeDrawingPadding()
|
||
) {
|
||
TopAppBar(
|
||
title = {
|
||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
Image(
|
||
painter = painterResource(R.drawable.ic_app_logo),
|
||
contentDescription = "MindMachine",
|
||
modifier = Modifier.size(60.dp)
|
||
)
|
||
Spacer(Modifier.width(8.dp))
|
||
Text("MindMachine")
|
||
}
|
||
},
|
||
actions = {
|
||
IconButton(
|
||
onClick = {
|
||
vm.createNewProgramDraft()
|
||
nav.navigate("setup")
|
||
}
|
||
) {
|
||
Icon(Icons.Filled.Add, contentDescription = "Add")
|
||
}
|
||
IconButton(onClick = { nav.navigate("settings") }) {
|
||
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||
}
|
||
}
|
||
)
|
||
LazyColumn(
|
||
Modifier
|
||
.weight(1f)
|
||
.padding(12.dp),
|
||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||
) {
|
||
item {
|
||
TextButton(onClick = { nav.navigate("holder") }) { Text("User Guidance/Disclaimer") }
|
||
}
|
||
items(ui.presets) { p ->
|
||
Card(
|
||
colors = CardDefaults.cardColors(
|
||
containerColor = MaterialTheme.colorScheme.surface,
|
||
contentColor = MaterialTheme.colorScheme.onSurface
|
||
),
|
||
modifier = Modifier.fillMaxWidth().clickable {
|
||
vm.choosePreset(p.id)
|
||
nav.navigate("setup")
|
||
}.padding(4.dp)
|
||
) {
|
||
Row(
|
||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||
horizontalArrangement = Arrangement.SpaceBetween,
|
||
verticalAlignment = Alignment.CenterVertically,
|
||
) {
|
||
Column(Modifier.weight(1f)) {
|
||
Text(p.name, style = MaterialTheme.typography.titleMedium)
|
||
Text(p.description)
|
||
Text("${formatDuration(p.defaultDurationSec)} • ${p.visualPatternType} • binaural ${formatMaxTwoDecimals(p.binauralDifferenceHz)}")
|
||
}
|
||
Row(
|
||
verticalAlignment = Alignment.CenterVertically,
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||
) {
|
||
IconButton(
|
||
onClick = {
|
||
val go = {
|
||
val started = vm.startSessionFromProgramSelection(p.id)
|
||
if (started) {
|
||
nav.navigate("active/$SESSION_ENTRY_HOME")
|
||
}
|
||
}
|
||
|
||
if (ui.settings.noAds) {
|
||
go()
|
||
} else {
|
||
interstitial?.show(onDone = go) ?: go()
|
||
}
|
||
},
|
||
modifier = Modifier.size(48.dp)
|
||
) {
|
||
Icon(
|
||
imageVector = Icons.Filled.PlayArrow,
|
||
contentDescription = "Play ${p.name}",
|
||
tint = Color(0xFF4CAF50),
|
||
modifier = Modifier.size(38.dp)
|
||
)
|
||
}
|
||
if (p.id in ui.deletableProgramIds) {
|
||
Box(
|
||
modifier = Modifier
|
||
.size(24.dp)
|
||
.clickable {
|
||
confirmDeleteProgramId = p.id
|
||
confirmDeleteProgramName = p.name
|
||
},
|
||
contentAlignment = Alignment.Center
|
||
) {
|
||
Icon(
|
||
imageVector = Icons.Filled.Delete,
|
||
contentDescription = "Delete ${p.name}",
|
||
tint = Color(0xFFFF5252),
|
||
modifier = Modifier.fillMaxSize()
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
AdBanner(
|
||
enabled = !ui.settings.noAds,
|
||
modifier = Modifier.fillMaxWidth()
|
||
)
|
||
}
|
||
|
||
if (confirmDeleteProgramId != null) {
|
||
AlertDialog(
|
||
onDismissRequest = {
|
||
confirmDeleteProgramId = null
|
||
confirmDeleteProgramName = ""
|
||
},
|
||
title = { Text("Delete program?") },
|
||
text = { Text("Delete \"$confirmDeleteProgramName\"?") },
|
||
confirmButton = {
|
||
TextButton(onClick = {
|
||
confirmDeleteProgramId?.let(vm::deleteProgram)
|
||
confirmDeleteProgramId = null
|
||
confirmDeleteProgramName = ""
|
||
}) {
|
||
Text("Delete", color = Color(0xFFFF5252))
|
||
}
|
||
},
|
||
dismissButton = {
|
||
TextButton(onClick = {
|
||
confirmDeleteProgramId = null
|
||
confirmDeleteProgramName = ""
|
||
}) { Text("Cancel") }
|
||
}
|
||
)
|
||
}
|
||
}
|
||
composable("setup") {
|
||
val interstitial = rememberInterstitialController(enabled = !ui.settings.noAds)
|
||
SetupScreen(
|
||
vm = vm,
|
||
onStart = {
|
||
val go = {
|
||
val started = vm.startSession()
|
||
if (started) {
|
||
nav.navigate("active/$SESSION_ENTRY_SETUP")
|
||
}
|
||
}
|
||
|
||
if (ui.settings.noAds) {
|
||
go()
|
||
} else {
|
||
interstitial?.show(onDone = go) ?: go()
|
||
}
|
||
},
|
||
)
|
||
}
|
||
composable(
|
||
route = "active/{entryPoint}",
|
||
arguments = listOf(navArgument("entryPoint") { type = NavType.StringType })
|
||
) { backStackEntry ->
|
||
val entryPoint = backStackEntry.arguments?.getString("entryPoint") ?: SESSION_ENTRY_SETUP
|
||
ActiveSessionScreen(
|
||
vm = vm,
|
||
onFinish = {
|
||
nav.navigate(entryPoint) {
|
||
popUpTo("active/$entryPoint") { inclusive = true }
|
||
launchSingleTop = true
|
||
}
|
||
},
|
||
onBackToEntry = {
|
||
vm.stop()
|
||
nav.navigate(entryPoint) {
|
||
popUpTo("active/$entryPoint") { inclusive = true }
|
||
launchSingleTop = true
|
||
}
|
||
}
|
||
)
|
||
}
|
||
composable("settings") {
|
||
SettingsScreen(vm, onRemoveAds = { nav.navigate("remove_ads") })
|
||
}
|
||
composable("remove_ads") {
|
||
RemoveAdsScreen(billingManager = billingManager, vm = vm)
|
||
}
|
||
composable("holder") {
|
||
PositioningScreen()
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
fun SimpleScreen(title: String, subtitle: String, actions: @Composable ColumnScope.() -> Unit) {
|
||
Column(
|
||
modifier = Modifier
|
||
.fillMaxSize()
|
||
.background(MaterialTheme.colorScheme.background)
|
||
.safeDrawingPadding()
|
||
.padding(16.dp),
|
||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||
) {
|
||
Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground)
|
||
Text(subtitle, color = MaterialTheme.colorScheme.onBackground)
|
||
actions()
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
fun SafetyScreen(onAck: () -> Unit, onHolder: () -> Unit) {
|
||
var checked by remember { mutableStateOf(false) }
|
||
Column(
|
||
Modifier
|
||
.fillMaxSize()
|
||
.background(MaterialTheme.colorScheme.background)
|
||
.safeDrawingPadding()
|
||
.padding(16.dp)
|
||
) {
|
||
Text("Read before using MindMachine", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
|
||
Spacer(Modifier.height(12.dp))
|
||
Text(
|
||
"Flashing lights may be unsafe for people with epilepsy, seizure sensitivity, or migraine triggers.\n\nDo not use while driving, walking, cycling, or operating machinery.\n\nFor immersive visual sessions, keep your eyes closed. Do not stare at the screen.\n\nDo not place the phone directly on your eyes. Avoid pressure on face/eyes and ensure comfortable breathing/airflow.\n\nUse at your own risk. This app is a prototype and not a medical device.\n\nStop immediately for discomfort, dizziness, headache, nausea, anxiety, or eye strain.\n\nBinaural mode requires stereo headphones.",
|
||
color = MaterialTheme.colorScheme.onBackground
|
||
)
|
||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
Checkbox(checked = checked, onCheckedChange = { checked = it })
|
||
Text("I understand the risks and will stop immediately if I feel discomfort.", color = MaterialTheme.colorScheme.onBackground)
|
||
}
|
||
Button(onClick = onAck, enabled = checked, modifier = Modifier.semantics { contentDescription = "I Understand" }) { Text("I Understand") }
|
||
TextButton(onClick = onHolder) { Text("Positioning Guidance") }
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
fun SetupScreen(
|
||
vm: MainViewModel,
|
||
onStart: () -> Unit,
|
||
) {
|
||
val ui by vm.ui.collectAsStateWithLifecycle()
|
||
val scrollState = rememberScrollState()
|
||
val containerModifier = Modifier
|
||
.fillMaxSize()
|
||
.background(MaterialTheme.colorScheme.background)
|
||
.windowInsetsPadding(WindowInsets.statusBars)
|
||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||
.then(if (setupScreenUsesScrollableContainer()) Modifier.verticalScroll(scrollState) else Modifier)
|
||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||
|
||
var programName by remember(ui.selectedPreset.id) { mutableStateOf(ui.selectedPreset.name) }
|
||
|
||
val hasUnsavedChanges = setupScreenHasUnsavedChanges(
|
||
hasUnsavedTimelineChanges = ui.hasUnsavedChanges,
|
||
editedProgramName = programName,
|
||
savedProgramName = ui.savedProgramName,
|
||
)
|
||
|
||
Column(
|
||
modifier = containerModifier,
|
||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||
) {
|
||
Text(
|
||
ui.selectedPreset.name,
|
||
style = MaterialTheme.typography.headlineSmall,
|
||
color = MaterialTheme.colorScheme.onBackground,
|
||
)
|
||
|
||
TextField(
|
||
value = programName,
|
||
onValueChange = { programName = it },
|
||
label = { Text("Program name") },
|
||
modifier = Modifier.fillMaxWidth(),
|
||
)
|
||
|
||
DurationSliderCard(
|
||
durationSec = ui.timeline.durationSec,
|
||
onDurationSecChanged = { vm.setDurationSec(it) },
|
||
)
|
||
|
||
// Integrated timeline editor (no separate screen).
|
||
SetupTimelineEditor(
|
||
state = ui.timeline,
|
||
onStateChanged = vm::updateTimeline,
|
||
onCurveGranularityChanged = vm::setCurveGranularitySec,
|
||
)
|
||
|
||
FlashSequenceEditor(
|
||
sequence = ui.timeline.flashSequence,
|
||
onSequenceChanged = { sequence -> vm.updateTimeline(ui.timeline.withFlashSequence(sequence)) },
|
||
)
|
||
|
||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||
if (hasUnsavedChanges) {
|
||
OutlinedButton(
|
||
onClick = { vm.saveCurrentProgram(programName) },
|
||
modifier = Modifier.weight(1f).height(52.dp)
|
||
) {
|
||
Text("Save")
|
||
}
|
||
} else {
|
||
Button(onClick = onStart, modifier = Modifier.weight(1f).height(52.dp)) { Text("Start") }
|
||
}
|
||
}
|
||
if (ui.error != null) Text(ui.error!!, color = if (ui.error!!.startsWith("Saved")) Color(0xFF72E39A) else MaterialTheme.colorScheme.error)
|
||
Spacer(Modifier.height(8.dp))
|
||
}
|
||
}
|
||
|
||
internal fun setupScreenFlashIntervalLabel(flashIntervalMs: Int): String =
|
||
"Flash interval: ${"%.2f".format(flashIntervalMsToSeconds(flashIntervalMs))} s"
|
||
|
||
internal fun formatMaxTwoDecimals(value: Float): String =
|
||
String.format(Locale.US, "%.2f", value).trimEnd('0').trimEnd('.')
|
||
|
||
internal fun setupScreenHasUnsavedChanges(
|
||
hasUnsavedTimelineChanges: Boolean,
|
||
editedProgramName: String,
|
||
savedProgramName: String,
|
||
): Boolean {
|
||
val nameChanged = editedProgramName.trim() != savedProgramName.trim()
|
||
return hasUnsavedTimelineChanges || nameChanged
|
||
}
|
||
|
||
internal fun setupScreenUsesScrollableContainer(): Boolean = true
|
||
|
||
@Composable
|
||
fun ActiveSessionScreen(vm: MainViewModel, onFinish: () -> Unit, onBackToEntry: () -> Unit) {
|
||
val ui by vm.ui.collectAsStateWithLifecycle()
|
||
if (ui.runtimeState == RuntimeState.COMPLETED) onFinish()
|
||
|
||
BackHandler {
|
||
onBackToEntry()
|
||
}
|
||
|
||
val isVisualMode = true
|
||
val context = LocalContext.current
|
||
DisposableEffect(isVisualMode) {
|
||
val activity = context as? Activity
|
||
val previous = activity?.requestedOrientation
|
||
if (isVisualMode) {
|
||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||
}
|
||
onDispose {
|
||
if (isVisualMode) {
|
||
activity?.requestedOrientation = previous ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||
}
|
||
}
|
||
}
|
||
|
||
val immersiveFullscreen = shouldUseImmersiveFullscreen(isVisualMode, ui.runtimeState)
|
||
var revealSystemBarsSignal by remember { mutableIntStateOf(0) }
|
||
ApplyImmersiveMode(
|
||
enabled = immersiveFullscreen,
|
||
revealSignal = revealSystemBarsSignal,
|
||
)
|
||
|
||
var showOverlay by remember(ui.runtimeState) { mutableStateOf(shouldShowActiveControlsByDefault(ui.runtimeState)) }
|
||
LaunchedEffect(ui.runtimeState) {
|
||
showOverlay = shouldShowActiveControlsByDefault(ui.runtimeState)
|
||
}
|
||
var brightnessOverlayVisible by remember { mutableStateOf(false) }
|
||
var brightnessOverlayToken by remember { mutableIntStateOf(0) }
|
||
val timingLogTag = "MindMachineTiming"
|
||
|
||
LaunchedEffect(brightnessOverlayToken) {
|
||
if (!brightnessOverlayVisible) return@LaunchedEffect
|
||
delay(2500)
|
||
brightnessOverlayVisible = false
|
||
}
|
||
|
||
LaunchedEffect(ui.runtimeState) {
|
||
when (ui.runtimeState) {
|
||
RuntimeState.COUNTDOWN -> Log.d(timingLogTag, "COUNTDOWN_START")
|
||
RuntimeState.RUNNING -> Log.d(timingLogTag, "IMMERSIVE_START")
|
||
RuntimeState.STOPPED, RuntimeState.COMPLETED, RuntimeState.PAUSED, RuntimeState.INTERRUPTED, RuntimeState.ERROR -> {
|
||
Log.d(timingLogTag, "IMMERSIVE_STOP state=${ui.runtimeState}")
|
||
}
|
||
else -> Unit
|
||
}
|
||
}
|
||
|
||
ApplyKeepScreenOn(enabled = true)
|
||
|
||
ApplyScreenBrightnessOverride(
|
||
enabled = true,
|
||
brightnessPercent = ui.settings.immersiveBrightnessPercent,
|
||
)
|
||
|
||
ApplyPreferredRefreshRate(
|
||
enabled = true,
|
||
preferLowest = ui.settings.preferLowestRefreshRate,
|
||
)
|
||
|
||
val progress = sessionProgressFraction(
|
||
durationSec = ui.timeline.durationSec,
|
||
remainingSec = ui.remainingSec,
|
||
)
|
||
|
||
Box(
|
||
modifier = Modifier
|
||
.fillMaxSize()
|
||
.background(Color.Black)
|
||
.then(if (immersiveFullscreen) Modifier else Modifier.safeDrawingPadding())
|
||
.pointerInput(immersiveFullscreen) {
|
||
detectTapGestures {
|
||
brightnessOverlayVisible = true
|
||
brightnessOverlayToken++
|
||
if (!immersiveFullscreen) {
|
||
showOverlay = !showOverlay
|
||
}
|
||
}
|
||
}
|
||
) {
|
||
if (isVisualMode) {
|
||
AndroidView(
|
||
modifier = Modifier.fillMaxSize(),
|
||
factory = { ctx ->
|
||
SplitFlashRenderView(ctx).apply {
|
||
setRunning(ui.runtimeState == RuntimeState.RUNNING)
|
||
updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
|
||
setFlashSequence(ui.config.flashSequence)
|
||
setMonochrome(ui.config.monochromeFlashMode)
|
||
}
|
||
},
|
||
update = { view ->
|
||
view.updateIntervals(ui.runtimeParams.flashOnMs, ui.runtimeParams.flashOffMs)
|
||
view.setFlashSequence(ui.config.flashSequence)
|
||
view.setMonochrome(ui.config.monochromeFlashMode)
|
||
view.setRunning(ui.runtimeState == RuntimeState.RUNNING)
|
||
}
|
||
)
|
||
}
|
||
|
||
if (ui.runtimeState == RuntimeState.COUNTDOWN) {
|
||
Text(
|
||
"${ui.countdownSec}",
|
||
modifier = Modifier.align(Alignment.Center),
|
||
style = MaterialTheme.typography.displayLarge,
|
||
color = Color.White
|
||
)
|
||
}
|
||
|
||
if (immersiveFullscreen && ui.settings.showImmersiveProgressBar) {
|
||
LinearProgressIndicator(
|
||
progress = { progress },
|
||
modifier = Modifier
|
||
.align(Alignment.BottomCenter)
|
||
.fillMaxWidth()
|
||
.height(5.dp),
|
||
trackColor = Color.White.copy(alpha = 0.2f),
|
||
color = Color.White,
|
||
)
|
||
}
|
||
|
||
if (brightnessOverlayVisible) {
|
||
Column(
|
||
Modifier
|
||
.align(Alignment.TopCenter)
|
||
.padding(top = 24.dp)
|
||
.fillMaxWidth(0.45f)
|
||
.background(Color.Black.copy(alpha = 0.72f))
|
||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||
) {
|
||
Text(
|
||
text = "Brightness ${ui.settings.immersiveBrightnessPercent}%",
|
||
color = Color.White,
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
)
|
||
Slider(
|
||
value = ui.settings.immersiveBrightnessPercent.toFloat(),
|
||
onValueChange = {
|
||
vm.updateImmersiveBrightnessPercent(it.roundToInt())
|
||
brightnessOverlayVisible = true
|
||
brightnessOverlayToken++
|
||
},
|
||
valueRange = 5f..100f,
|
||
)
|
||
}
|
||
}
|
||
|
||
if (showOverlay) {
|
||
Column(
|
||
Modifier
|
||
.align(Alignment.BottomCenter)
|
||
.fillMaxWidth()
|
||
.background(Color.Black.copy(alpha = 0.72f))
|
||
.padding(16.dp)
|
||
) {
|
||
Text("${ui.selectedPreset.name} • ${ui.remainingSec}s", color = Color.White)
|
||
if (ui.runtimeState == RuntimeState.RUNNING) {
|
||
Button(onClick = { vm.pause() }, modifier = Modifier.fillMaxWidth()) { Text("Pause") }
|
||
} else {
|
||
Button(onClick = { vm.resume() }, modifier = Modifier.fillMaxWidth()) { Text("Resume") }
|
||
}
|
||
OutlinedButton(
|
||
onClick = {
|
||
vm.stop()
|
||
onFinish()
|
||
},
|
||
modifier = Modifier.fillMaxWidth()
|
||
) { Text("Stop") }
|
||
if (ui.interruptionReason != null) {
|
||
Text(ui.interruptionReason!!, color = Color.White)
|
||
if (ui.runtimeState == RuntimeState.INTERRUPTED) {
|
||
OutlinedButton(onClick = { vm.switchToVisualOnlyAndResume() }, modifier = Modifier.fillMaxWidth()) {
|
||
Text("Resume Visual-only")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun ApplyKeepScreenOn(enabled: Boolean) {
|
||
val activity = LocalContext.current as? Activity ?: return
|
||
val window = activity.window
|
||
|
||
DisposableEffect(window, enabled) {
|
||
if (enabled) {
|
||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||
} else {
|
||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||
}
|
||
onDispose {
|
||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun ApplyScreenBrightnessOverride(enabled: Boolean, brightnessPercent: Int) {
|
||
val activity = LocalContext.current as? Activity ?: return
|
||
val window = activity.window
|
||
|
||
DisposableEffect(window) {
|
||
onDispose {
|
||
val reset = window.attributes
|
||
reset.screenBrightness = -1f
|
||
window.attributes = reset
|
||
}
|
||
}
|
||
|
||
LaunchedEffect(enabled, brightnessPercent) {
|
||
val params = window.attributes
|
||
params.screenBrightness = if (enabled) {
|
||
(brightnessPercent.coerceIn(5, 100) / 100f)
|
||
} else {
|
||
-1f
|
||
}
|
||
window.attributes = params
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun ApplyPreferredRefreshRate(enabled: Boolean, preferLowest: Boolean) {
|
||
val activity = LocalContext.current as? Activity ?: return
|
||
val window = activity.window
|
||
val timingLogTag = "MindMachineTiming"
|
||
|
||
DisposableEffect(window) {
|
||
onDispose {
|
||
val params = window.attributes
|
||
params.preferredRefreshRate = 0f
|
||
params.preferredDisplayModeId = 0
|
||
window.attributes = params
|
||
}
|
||
}
|
||
|
||
LaunchedEffect(enabled, preferLowest) {
|
||
val params = window.attributes
|
||
if (enabled && preferLowest) {
|
||
val display = activity.display
|
||
val chosenMode = display?.supportedModes?.minByOrNull { it.refreshRate }
|
||
params.preferredDisplayModeId = chosenMode?.modeId ?: 0
|
||
params.preferredRefreshRate = chosenMode?.refreshRate ?: 0f
|
||
Log.d(
|
||
timingLogTag,
|
||
"DISPLAY_MODE_REQUEST modeId=${chosenMode?.modeId ?: 0} refreshHz=${chosenMode?.refreshRate ?: 0f} supported=${display?.supportedModes?.joinToString { "${it.modeId}:${it.refreshRate}" }}"
|
||
)
|
||
} else {
|
||
params.preferredDisplayModeId = 0
|
||
params.preferredRefreshRate = 0f
|
||
}
|
||
window.attributes = params
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun ApplyImmersiveMode(enabled: Boolean, revealSignal: Int) {
|
||
val context = LocalContext.current
|
||
val activity = context as? Activity ?: return
|
||
val window = activity.window
|
||
val controller = remember(window) { WindowInsetsControllerCompat(window, window.decorView) }
|
||
|
||
DisposableEffect(window) {
|
||
onDispose {
|
||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||
}
|
||
}
|
||
|
||
LaunchedEffect(enabled) {
|
||
if (enabled) {
|
||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||
} else {
|
||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||
}
|
||
}
|
||
|
||
LaunchedEffect(revealSignal, enabled) {
|
||
if (!enabled || revealSignal == 0) return@LaunchedEffect
|
||
controller.show(WindowInsetsCompat.Type.systemBars())
|
||
delay(2500)
|
||
if (enabled) {
|
||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||
}
|
||
}
|
||
}
|
||
|
||
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 monochrome = false
|
||
private var flashSequence = solutions.tretter.mindmachine.session.defaultFlashSequence()
|
||
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) % phaseCount()
|
||
toggleCount += 1
|
||
invalidate()
|
||
|
||
val nextDuration = if (isVisiblePhase(phase)) 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) {
|
||
val newRawOnMs = flashOnMs.coerceIn(50, 2000).toLong()
|
||
val newRawOffMs = flashOffMs.coerceIn(50, 2000).toLong()
|
||
val frameMs = framePeriodMs().coerceAtLeast(1L)
|
||
val newOnMs = snapToFrameMs(newRawOnMs, frameMs)
|
||
val newOffMs = snapToFrameMs(newRawOffMs, frameMs)
|
||
|
||
val changed = newRawOnMs != rawOnMs || newRawOffMs != rawOffMs || newOnMs != onMs || newOffMs != offMs
|
||
if (!changed) return
|
||
|
||
rawOnMs = newRawOnMs
|
||
rawOffMs = newRawOffMs
|
||
onMs = newOnMs
|
||
offMs = newOffMs
|
||
|
||
if (isRunning) {
|
||
Log.d(
|
||
timingLogTag,
|
||
"config onMs=$rawOnMs offMs=$rawOffMs refreshHz=${"%.2f".format(refreshRate())} frameMs=$frameMs snappedOnMs=$onMs snappedOffMs=$offMs"
|
||
)
|
||
}
|
||
|
||
if (isRunning && started) {
|
||
val now = SystemClock.uptimeMillis()
|
||
val nextDuration = if (isVisiblePhase(phase)) onMs else offMs
|
||
nextToggleUptimeMs = maxOf(nextToggleUptimeMs, now + 1L)
|
||
val remaining = (nextToggleUptimeMs - now).coerceAtLeast(1L)
|
||
if (remaining > nextDuration) {
|
||
nextToggleUptimeMs = now + nextDuration
|
||
}
|
||
scheduleNext()
|
||
}
|
||
}
|
||
|
||
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)} modeId=${display?.mode?.modeId ?: 0} refreshHz=${"%.2f".format(refreshRate())} frameMs=${framePeriodMs()} nowMs=$startUptimeMs"
|
||
)
|
||
invalidate()
|
||
scheduleNext()
|
||
}
|
||
|
||
fun setMonochrome(enabled: Boolean) {
|
||
if (monochrome == enabled) return
|
||
monochrome = enabled
|
||
invalidate()
|
||
}
|
||
|
||
fun setFlashSequence(sequence: List<solutions.tretter.mindmachine.session.FlashSequenceRow>) {
|
||
val normalized = sequence.ifEmpty { solutions.tretter.mindmachine.session.defaultFlashSequence() }
|
||
if (flashSequence == normalized) return
|
||
flashSequence = normalized
|
||
if (started) {
|
||
phase %= phaseCount()
|
||
}
|
||
invalidate()
|
||
}
|
||
|
||
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 colorsForPhase(phase: Int): Pair<Int, Int> {
|
||
if (!isVisiblePhase(phase)) return Pair(android.graphics.Color.BLACK, android.graphics.Color.BLACK)
|
||
if (monochrome) return Pair(android.graphics.Color.WHITE, android.graphics.Color.WHITE)
|
||
val row = flashSequence[(phase / 2).mod(flashSequence.size)]
|
||
val left = paletteColor(row.leftColor)
|
||
val right = if (row.splitScreen) paletteColor(row.rightColor) else left
|
||
return Pair(left, right)
|
||
}
|
||
|
||
private fun phaseName(phase: Int): String {
|
||
if (!isVisiblePhase(phase)) return "BLACK"
|
||
val row = flashSequence[(phase / 2).mod(flashSequence.size)]
|
||
return if (row.splitScreen) {
|
||
"${row.leftColor.name}_${row.rightColor.name}"
|
||
} else {
|
||
row.leftColor.name
|
||
}
|
||
}
|
||
|
||
private fun isVisiblePhase(phase: Int): Boolean = phase % 2 == 0
|
||
|
||
private fun phaseCount(): Int = (flashSequence.size.coerceAtLeast(1)) * 2
|
||
|
||
private fun paletteColor(color: solutions.tretter.mindmachine.session.FlashPaletteColor): Int = when (color) {
|
||
solutions.tretter.mindmachine.session.FlashPaletteColor.BLACK -> android.graphics.Color.BLACK
|
||
solutions.tretter.mindmachine.session.FlashPaletteColor.WHITE -> android.graphics.Color.WHITE
|
||
solutions.tretter.mindmachine.session.FlashPaletteColor.RED -> android.graphics.Color.RED
|
||
solutions.tretter.mindmachine.session.FlashPaletteColor.GREEN -> android.graphics.Color.GREEN
|
||
solutions.tretter.mindmachine.session.FlashPaletteColor.YELLOW -> android.graphics.Color.YELLOW
|
||
solutions.tretter.mindmachine.session.FlashPaletteColor.BLUE -> android.graphics.Color.BLUE
|
||
}
|
||
|
||
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
|
||
FlashColor.BLACK -> Color.Black
|
||
}
|
||
|
||
@Composable
|
||
private fun rememberSplitFlashFrame(
|
||
isRunning: Boolean,
|
||
flashOnMs: Int,
|
||
flashOffMs: Int,
|
||
): androidx.compose.runtime.State<SplitFlashFrame> {
|
||
val latestFlashOnMs = rememberUpdatedState(flashOnMs)
|
||
val latestFlashOffMs = rememberUpdatedState(flashOffMs)
|
||
|
||
// The screen can only change on vsync boundaries. If the configured interval is not a
|
||
// multiple of the refresh period, the *visual* interval will wobble by ~1 frame.
|
||
// Snapping intervals to whole frames makes the blink look uniform.
|
||
val refreshRateHz = LocalView.current.display?.refreshRate ?: 60f
|
||
val framePeriodNanos = remember(refreshRateHz) {
|
||
(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)
|
||
return@produceState
|
||
}
|
||
|
||
// Drive the blink schedule from the frame clock, but keep phase boundaries aligned
|
||
// to a monotonic time base (frameTimeNanos) so the *configured timing* stays steady
|
||
// even if some frames are late.
|
||
//
|
||
// Key idea: instead of "advance by whatever elapsed since last frame", we keep an
|
||
// explicit nextToggleTime and only advance the phase when now >= nextToggleTime.
|
||
var started = false
|
||
var phase = 0 // 0=RG, 1=BK, 2=GR, 3=BK
|
||
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)
|
||
1 -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
|
||
2 -> SplitFlashFrame(left = FlashColor.GREEN, right = FlashColor.RED)
|
||
else -> SplitFlashFrame(left = FlashColor.BLACK, right = FlashColor.BLACK)
|
||
}
|
||
|
||
fun updateDurations() {
|
||
val rawOn = latestFlashOnMs.value.coerceIn(50, 2000).toLong() * 1_000_000L
|
||
val rawOff = latestFlashOffMs.value.coerceIn(50, 2000).toLong() * 1_000_000L
|
||
|
||
val onFrames = ((rawOn + framePeriodNanos / 2) / framePeriodNanos).coerceAtLeast(1L)
|
||
val offFrames = ((rawOff + framePeriodNanos / 2) / framePeriodNanos).coerceAtLeast(1L)
|
||
|
||
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) {
|
||
withFrameNanos { now ->
|
||
if (!started) {
|
||
updateDurations()
|
||
phase = 0
|
||
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
|
||
}
|
||
|
||
updateDurations()
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
fun SettingsScreen(vm: MainViewModel, onRemoveAds: () -> Unit) {
|
||
val ui by vm.ui.collectAsStateWithLifecycle()
|
||
val scrollState = rememberScrollState()
|
||
Column(
|
||
Modifier
|
||
.fillMaxSize()
|
||
.background(MaterialTheme.colorScheme.background)
|
||
.safeDrawingPadding()
|
||
.verticalScroll(scrollState)
|
||
.padding(16.dp),
|
||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||
) {
|
||
Text("Settings", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
|
||
|
||
Text("Countdown: ${ui.settings.countdownSeconds}s", color = MaterialTheme.colorScheme.onBackground)
|
||
Slider(
|
||
value = ui.settings.countdownSeconds.toFloat(),
|
||
onValueChange = { vm.updateCountdownSeconds(it.roundToInt()) },
|
||
valueRange = 0f..10f,
|
||
steps = 9,
|
||
)
|
||
|
||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
Checkbox(
|
||
checked = ui.settings.forceAudioWithoutHeadphones,
|
||
onCheckedChange = vm::updateForceAudioWithoutHeadphones,
|
||
)
|
||
Text("force audio without headphones", color = MaterialTheme.colorScheme.onBackground)
|
||
}
|
||
|
||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
Checkbox(checked = ui.settings.showImmersiveProgressBar, onCheckedChange = vm::updateShowImmersiveProgressBar)
|
||
Text("Show immersive bottom progress bar", color = MaterialTheme.colorScheme.onBackground)
|
||
}
|
||
|
||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||
Checkbox(checked = ui.settings.preferLowestRefreshRate, onCheckedChange = vm::updatePreferLowestRefreshRate)
|
||
Text("Prefer lowest display refresh rate during sessions", color = MaterialTheme.colorScheme.onBackground)
|
||
}
|
||
|
||
Button(onClick = onRemoveAds) {
|
||
Text(if (ui.settings.noAds) "Ads removed" else "Remove ads")
|
||
}
|
||
|
||
HorizontalDivider()
|
||
Text(
|
||
"About/Disclaimer: MindMachine is a prototype and not a medical device. Use at your own risk. For immersive visual sessions, keep your eyes closed.",
|
||
color = MaterialTheme.colorScheme.onBackground
|
||
)
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
fun RemoveAdsScreen(billingManager: solutions.tretter.mindmachine.billing.BillingManager, vm: MainViewModel) {
|
||
val ui by vm.ui.collectAsStateWithLifecycle()
|
||
val activity = LocalContext.current as? Activity
|
||
val details by billingManager.productDetails.collectAsStateWithLifecycle()
|
||
val logs by billingManager.logs.collectAsStateWithLifecycle()
|
||
|
||
val subDetails = details[solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY]
|
||
val subPrice = subDetails
|
||
?.subscriptionOfferDetails
|
||
?.firstOrNull()
|
||
?.pricingPhases
|
||
?.pricingPhaseList
|
||
?.firstOrNull()
|
||
?.formattedPrice
|
||
|
||
val inAppDetails = details[solutions.tretter.mindmachine.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER]
|
||
val inAppPrice = inAppDetails?.oneTimePurchaseOfferDetails?.formattedPrice
|
||
|
||
Column(
|
||
Modifier
|
||
.fillMaxSize()
|
||
.background(MaterialTheme.colorScheme.background)
|
||
.safeDrawingPadding()
|
||
.padding(16.dp),
|
||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||
) {
|
||
Text("Remove Ads", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
|
||
Text(
|
||
if (ui.settings.noAds) "You already have ads removed." else "MindMachine is free with ads. You can remove ads via subscription or one-time purchase.",
|
||
color = MaterialTheme.colorScheme.onBackground
|
||
)
|
||
|
||
Button(
|
||
enabled = activity != null && !ui.settings.noAds,
|
||
onClick = {
|
||
activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.SUB_REMOVE_ADS_MONTHLY) }
|
||
}
|
||
) {
|
||
Text("Subscribe: ${subPrice ?: "$0.99"}/month")
|
||
}
|
||
|
||
Button(
|
||
enabled = activity != null && !ui.settings.noAds,
|
||
onClick = {
|
||
activity?.let { billingManager.launchPurchase(it, solutions.tretter.mindmachine.billing.BillingProducts.INAPP_REMOVE_ADS_FOREVER) }
|
||
}
|
||
) {
|
||
Text("Buy: ${inAppPrice ?: "$19.99"} one-time")
|
||
}
|
||
|
||
TextButton(onClick = { billingManager.refresh() }) { Text("Restore / Refresh purchases") }
|
||
|
||
Text(
|
||
"Note: Purchases only work when the app is installed from Google Play (Internal testing/Production). Debug sideloaded builds won’t complete real billing flows.",
|
||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
||
)
|
||
|
||
// Debug logs
|
||
Text("Billing logs:", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
|
||
Column(
|
||
Modifier
|
||
.weight(1f)
|
||
.fillMaxWidth()
|
||
.verticalScroll(rememberScrollState())
|
||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f))
|
||
.padding(8.dp)
|
||
) {
|
||
logs.forEach { log ->
|
||
Text(log, color = MaterialTheme.colorScheme.onBackground, style = MaterialTheme.typography.bodySmall)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
fun PositioningScreen() {
|
||
val scrollState = rememberScrollState()
|
||
Column(
|
||
Modifier
|
||
.fillMaxSize()
|
||
.background(MaterialTheme.colorScheme.background)
|
||
.safeDrawingPadding()
|
||
.verticalScroll(scrollState)
|
||
.padding(16.dp),
|
||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||
) {
|
||
Text("Positioning Guidance", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
|
||
|
||
Image(
|
||
painter = painterResource(R.drawable.positioning_guide),
|
||
contentDescription = "Positioning guide",
|
||
contentScale = ContentScale.Fit,
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.height(260.dp)
|
||
.padding(vertical = 6.dp)
|
||
)
|
||
|
||
Text("• Lie on your back in a safe, comfortable place.", color = MaterialTheme.colorScheme.onBackground)
|
||
Text("• Rest the phone across the upper nose/forehead area so it stays stable.", color = MaterialTheme.colorScheme.onBackground)
|
||
Text("• Keep your eyes closed during immersive visual sessions.", color = MaterialTheme.colorScheme.onBackground)
|
||
|
||
Text("Warnings", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
|
||
Text("• Flashing lights may be unsafe for people with epilepsy, seizure sensitivity, or migraine triggers.", color = MaterialTheme.colorScheme.onBackground)
|
||
Text("• Do not stare at the screen, for immersive visual sessions, keep your eyes closed.", color = MaterialTheme.colorScheme.onBackground)
|
||
Text("• Stop immediately for discomfort, dizziness, headache, nausea, anxiety, or eye strain.", color = MaterialTheme.colorScheme.onBackground)
|
||
|
||
Spacer(Modifier.height(12.dp))
|
||
Text("Disclaimer", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
|
||
Text(
|
||
"MindMachine is provided \"as is\" for informational and experimental purposes only, without warranties of any kind. You assume all risks of use. MindMachine is not a medical device and does not provide medical advice. To the maximum extent permitted by law, the developers and distributors disclaim all liability for any claims, damages, losses, or injuries arising from or related to use of the app.",
|
||
color = MaterialTheme.colorScheme.onBackground
|
||
)
|
||
}
|
||
}
|