Added normalized time for AOS/LOS, fixed passes refresh

This commit is contained in:
Arty Bishop committed 2026-04-04 18:43:05 +01:00
1 parent b59f9a6c17
commit be96602c76
6 files changed
+116 -89

No files matched your search

@@ -54,8 +54,7 @@ class SatelliteRepo(
settingsRepo.selectedIds.collect { selectedIds -> settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) } _satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
val timeNow = 1000 * ((System.currentTimeMillis() + 500) / 1000) calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
calculatePasses(timeNow, hoursAhead, minElevation, modes)
} }
} }
@@ -95,27 +94,10 @@ class SatelliteRepo(
} }
} }
override suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
return withContext(dispatcher) {
val result = ArrayList<OrbitalPass>(passList.size)
for (pass in passList) {
if (!pass.isDeepSpace) {
val timeStart = pass.aosTime
if (time > timeStart) {
val deltaNow = time.minus(timeStart).toFloat()
val deltaTotal = pass.losTime.minus(timeStart).toFloat()
pass.progress = (deltaNow / deltaTotal).round(2)
}
}
if (pass.progress < 1.0f) {
result.add(pass.copy())
}
}
result
}
}
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) { override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
// Normalize to the start of the current minute so that coarse 60-second stepping
// in getLeoPass always begins from the same phase, producing stable AOS/LOS times
val normalizedTime = time / 60_000L * 60_000L
val currentSatellites = _satellites.value val currentSatellites = _satellites.value
if (currentSatellites.isNotEmpty()) { if (currentSatellites.isNotEmpty()) {
withContext(dispatcher) { withContext(dispatcher) {
@@ -129,13 +111,11 @@ class SatelliteRepo(
// Compute passes for each satellite in parallel // Compute passes for each satellite in parallel
val passLists = coroutineScope { val passLists = coroutineScope {
filteredSatellites.map { satellite -> filteredSatellites.map { satellite ->
async { async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) }
satellite.getPasses(stationPos, time, hoursAhead)
}
}.awaitAll() }.awaitAll()
} }
// Flatten and filter in a single pass // Flatten and filter in a single pass
val timeFuture = time + (hoursAhead * 60L * 60L * 1000L) val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L)
val newPasses = ArrayList<OrbitalPass>() val newPasses = ArrayList<OrbitalPass>()
for (list in passLists) { for (list in passLists) {
for (pass in list) { for (pass in list) {
@@ -177,7 +157,6 @@ class SatelliteRepo(
return passes return passes
} }
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass { private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass {
val satPos = sat.getPosition(pos, time) val satPos = sat.getPosition(pos, time)
val aos = time - 24 * 60L * 60L * 1000L val aos = time - 24 * 60L * 60L * 1000L
@@ -210,9 +189,7 @@ class SatelliteRepo(
do { do {
calendarTimeMillis += 60L * 1000L calendarTimeMillis += 60L * 1000L
elevation = sat.getElevation(pos, calendarTimeMillis) elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) { if (elevation > maxElevation) maxElevation = elevation
maxElevation = elevation
}
} while (elevation < 0.0) } while (elevation < 0.0)
// refine AOS to ~500ms precision // refine AOS to ~500ms precision
@@ -220,9 +197,7 @@ class SatelliteRepo(
do { do {
calendarTimeMillis += 500L calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis) elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) { if (elevation > maxElevation) maxElevation = elevation
maxElevation = elevation
}
} while (elevation < 0.0) } while (elevation < 0.0)
// Get full position for AOS data (azimuth, altitude) // Get full position for AOS data (azimuth, altitude)
@@ -234,9 +209,7 @@ class SatelliteRepo(
do { do {
calendarTimeMillis += 30L * 1000L calendarTimeMillis += 30L * 1000L
elevation = sat.getElevation(pos, calendarTimeMillis) elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) { if (elevation > maxElevation) maxElevation = elevation
maxElevation = elevation
}
} while (elevation > 0.0) } while (elevation > 0.0)
// refine LOS to ~500ms precision // refine LOS to ~500ms precision
@@ -244,9 +217,7 @@ class SatelliteRepo(
do { do {
calendarTimeMillis += 500L calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis) elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) { if (elevation > maxElevation) maxElevation = elevation
maxElevation = elevation
}
} while (elevation > 0.0) } while (elevation > 0.0)
// Get full position for LOS data (azimuth, altitude) // Get full position for LOS data (azimuth, altitude)
@@ -25,7 +25,7 @@ data class OrbitalPass(
val altitude: Int = 1000, val altitude: Int = 1000,
val maxElevation: Double = 75.0, val maxElevation: Double = 75.0,
val orbitalObject: OrbitalObject, val orbitalObject: OrbitalObject,
var progress: Float = 0.0f val progress: Float = 0.0f
) { ) {
val catNum: Int = orbitalObject.data.catnum val catNum: Int = orbitalObject.data.catnum
val name: String = orbitalObject.data.name val name: String = orbitalObject.data.name
@@ -25,13 +25,27 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface ISatelliteRepo { interface ISatelliteRepo {
val passes: StateFlow<List<OrbitalPass>> /** All selected OrbitalObjects, updated when the selection changes. */
val satellites: StateFlow<List<OrbitalObject>> val satellites: StateFlow<List<OrbitalObject>>
suspend fun getRadiosWithId(id: Int): List<SatRadio>
/** Raw calculated passes (without live progress). Updated on selection/filter change. */
val passes: StateFlow<List<OrbitalPass>>
/** Load satellite objects from DB based on the current selection. */
suspend fun initRepository() suspend fun initRepository()
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> /** Recalculate passes with the given filter parameters. */
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass>
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
/** Get the current position of a single satellite. */
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
/** Get the ground track for a satellite over a time range. */
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
/** Fetch radio transceivers for a satellite by its catalog number. */
suspend fun getRadiosWithId(id: Int): List<SatRadio>
} }
@@ -44,6 +44,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -223,6 +224,10 @@ private fun NearEarthPass(
) { ) {
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum) val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp
// Cache formatted date/time strings — aosTime/losTime never change for a given pass
val aosDateStr = remember(pass.aosTime) { sdfDate.format(Date(pass.aosTime)) }
val aosTimeStr = remember(pass.aosTime) { sdfTime.format(Date(pass.aosTime)) }
val losTimeStr = remember(pass.losTime) { sdfTime.format(Date(pass.losTime)) }
Surface(color = MaterialTheme.colorScheme.background, modifier = modifier) { Surface(color = MaterialTheme.colorScheme.background, modifier = modifier) {
Surface(modifier = Modifier Surface(modifier = Modifier
.padding(bottom = 2.dp) .padding(bottom = 2.dp)
@@ -274,7 +279,7 @@ private fun NearEarthPass(
Text(text = stringResource(R.string.pass_deep_space), fontSize = 15.sp) Text(text = stringResource(R.string.pass_deep_space), fontSize = 15.sp)
} else { } else {
Text( Text(
text = sdfDate.format(Date(pass.aosTime)), text = aosDateStr,
fontSize = 15.sp fontSize = 15.sp
) )
} }
@@ -317,7 +322,7 @@ private fun NearEarthPass(
) { ) {
val defaultTime = " - - : - - " val defaultTime = " - - : - - "
Text( Text(
text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.aosTime)), text = if (pass.isDeepSpace) defaultTime else aosTimeStr,
fontSize = 15.sp fontSize = 15.sp
) )
LinearProgressIndicator( LinearProgressIndicator(
@@ -326,7 +331,7 @@ private fun NearEarthPass(
modifier = modifier.fillMaxWidth(0.75f) modifier = modifier.fillMaxWidth(0.75f)
) )
Text( Text(
text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.losTime)), text = if (pass.isDeepSpace) defaultTime else losTimeStr,
fontSize = 15.sp fontSize = 15.sp
) )
} }
@@ -27,10 +27,9 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -44,6 +43,7 @@ class PassesViewModel(
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
) : ViewModel() { ) : ViewModel() {
private val defaultPass = getDefaultPass()
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
PassesState( PassesState(
isPassesDialogShown = false, isPassesDialogShown = false,
@@ -52,7 +52,7 @@ class PassesViewModel(
isUtc = settingsRepo.otherSettings.value.stateOfUtc, isUtc = settingsRepo.otherSettings.value.stateOfUtc,
nextTime = "00:00:00", nextTime = "00:00:00",
isNextTimeAos = true, isNextTimeAos = true,
nextPass = getDefaultPass(), nextPass = defaultPass,
hours = settingsRepo.passesSettings.value.hoursAhead, hours = settingsRepo.passesSettings.value.hoursAhead,
elevation = settingsRepo.passesSettings.value.minElevation, elevation = settingsRepo.passesSettings.value.minElevation,
modes = settingsRepo.passesSettings.value.selectedModes, modes = settingsRepo.passesSettings.value.selectedModes,
@@ -61,25 +61,36 @@ class PassesViewModel(
takeAction = ::handleAction takeAction = ::handleAction
) )
) )
private var processing: Job? = null
val uiState: StateFlow<PassesState> = _uiState val uiState: StateFlow<PassesState> = _uiState
init { init {
// React to raw pass list changes (initial load, recalculation, filter change)
viewModelScope.launch { viewModelScope.launch {
delay(1000) var initialLoadDone = false
satelliteRepo.passes.collectLatest { passes -> satelliteRepo.passes.collectLatest { passes ->
processing?.cancelAndJoin() if (!initialLoadDone && passes.isNotEmpty()) {
processing = viewModelScope.launch { initialLoadDone = true
_uiState.update { it.copy(isRefreshing = false) }
}
}
}
// Local tick loop — computes pass progress and countdown timer every second
viewModelScope.launch {
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val newPasses = satelliteRepo.processPasses(passes, timeNow) val processed = computePassProgress(satelliteRepo.passes.value, timeNow)
setPassInfo(newPasses, timeNow) val nextInfo = resolveNextPass(processed, timeNow)
_uiState.update { it.copy(isRefreshing = false, itemsList = newPasses) } _uiState.update {
it.copy(
itemsList = processed,
nextPass = nextInfo.first,
nextTime = nextInfo.second,
isNextTimeAos = nextInfo.third
)
}
delay(1000) delay(1000)
} }
} }
}
}
viewModelScope.launch { viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings -> settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(shouldSeeWhatsNew = settings.shouldSeeWhatsNew) } _uiState.update { it.copy(shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
@@ -87,6 +98,43 @@ class PassesViewModel(
} }
} }
/** Computes live progress for each pass, filtering out expired ones. */
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
val result = ArrayList<OrbitalPass>(passList.size)
for (pass in passList) {
if (!pass.isDeepSpace && time > pass.aosTime) {
val deltaNow = time.minus(pass.aosTime).toFloat()
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
val newProgress = (deltaNow / deltaTotal).round(2)
if (newProgress >= 1.0f) continue
if (newProgress != pass.progress) {
result.add(pass.copy(progress = newProgress))
} else {
result.add(pass)
}
} else {
result.add(pass)
}
}
return result
}
/** Resolves the next upcoming or active pass and its countdown timer. */
private fun resolveNextPass(
passes: List<OrbitalPass>,
timeNow: Long
): Triple<OrbitalPass, String, Boolean> {
val upcoming = passes.firstOrNull { it.aosTime.minus(timeNow) > 0 }
if (upcoming != null) {
return Triple(upcoming, upcoming.aosTime.minus(timeNow).toTimerString(), true)
}
if (passes.isNotEmpty()) {
val lastPass = passes.last()
return Triple(lastPass, lastPass.losTime.minus(timeNow).toTimerString(), false)
}
return Triple(defaultPass, "00:00:00", true)
}
private fun handleAction(action: PassesAction) { private fun handleAction(action: PassesAction) {
when (action) { when (action) {
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed() PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
@@ -100,30 +148,17 @@ class PassesViewModel(
private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch { private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) } _uiState.update { it.copy(isRefreshing = true) }
processing?.cancelAndJoin()
settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes)) settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes))
_uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) } _uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) }
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
_uiState.update { it.copy(isRefreshing = false) }
} }
private fun refreshPasses() = viewModelScope.launch { private fun refreshPasses() = viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) } _uiState.update { it.copy(isRefreshing = true) }
processing?.cancelAndJoin()
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes)
} _uiState.update { it.copy(isRefreshing = false) }
private fun setPassInfo(passes: List<OrbitalPass>, timeNow: Long) {
if (passes.isEmpty()) return
try {
val nextPass = passes.first { it.aosTime.minus(timeNow) > 0 }
val time = nextPass.aosTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(nextPass = nextPass, nextTime = time, isNextTimeAos = true) }
} catch (_: NoSuchElementException) {
val lastPass = passes.last()
val time = lastPass.losTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(nextPass = lastPass, nextTime = time, isNextTimeAos = false) }
}
} }
private fun toggleFilterDialog() { private fun toggleFilterDialog() {
@@ -26,7 +26,6 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IReporter import com.rtbishop.look4sat.core.domain.repository.IReporter
@@ -75,6 +74,7 @@ class RadarViewModel(
val uiState: StateFlow<RadarState> = _uiState val uiState: StateFlow<RadarState> = _uiState
init { init {
// Compass sensor collection
if (settingsRepo.otherSettings.value.stateOfSensors) { if (settingsRepo.otherSettings.value.stateOfSensors) {
viewModelScope.launch { viewModelScope.launch {
sensorsRepo.enableSensor() sensorsRepo.enableSensor()
@@ -84,6 +84,7 @@ class RadarViewModel(
} }
} }
} }
// Resolve which pass we're tracking
viewModelScope.launch { viewModelScope.launch {
val catNum = savedStateHandle.get<Int>("catNum") ?: 0 val catNum = savedStateHandle.get<Int>("catNum") ?: 0
val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L val aosTime = savedStateHandle.get<Long>("aosTime") ?: 0L
@@ -93,25 +94,33 @@ class RadarViewModel(
currentPass?.let { satPass -> currentPass?.let { satPass ->
_uiState.update { it.copy(currentPass = satPass) } _uiState.update { it.copy(currentPass = satPass) }
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum) val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum)
// Compute track once (it doesn't change)
if (!satPass.isDeepSpace) {
val track = satelliteRepo.getTrack(
satPass.orbitalObject, stationPos, satPass.aosTime, satPass.losTime
)
_uiState.update { it.copy(satTrack = track) }
}
// Local tick loop — computes position only while the radar screen is alive
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow) val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow)
when { when {
satPass.isDeepSpace -> { satPass.isDeepSpace -> {
val time = 0L.toTimerString() val time = 0L.toTimerString()
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) } _uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false, orbitalPos = pos) }
} }
satPass.aosTime > timeNow -> { satPass.aosTime > timeNow -> {
val time = satPass.aosTime.minus(timeNow).toTimerString() val time = satPass.aosTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = true) } _uiState.update { it.copy(currentTime = time, isCurrentTimeAos = true, orbitalPos = pos) }
} }
else -> { else -> {
val time = satPass.losTime.minus(timeNow).toTimerString() val time = satPass.losTime.minus(timeNow).toTimerString()
_uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) } _uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false, orbitalPos = pos) }
} }
} }
processRadios(transmitters, satPass.orbitalObject, timeNow) processRadios(transmitters, satPass.orbitalObject, timeNow)
sendPassData(satPass, pos, satPass.orbitalObject) sendPassData(pos)
sendPassDataBT(pos) sendPassDataBT(pos)
delay(1000) delay(1000)
} }
@@ -131,14 +140,7 @@ class RadarViewModel(
} }
} }
private suspend fun sendPassData(orbitalPass: OrbitalPass, orbitalPos: OrbitalPos, orbitalObject: OrbitalObject) { private fun sendPassData(orbitalPos: OrbitalPos) {
var track: List<OrbitalPos> = emptyList()
if (!orbitalPass.isDeepSpace) {
track = satelliteRepo.getTrack(
orbitalObject, stationPos, orbitalPass.aosTime, orbitalPass.losTime
)
}
_uiState.update { it.copy(orbitalPos = orbitalPos, satTrack = track) }
viewModelScope.launch { viewModelScope.launch {
val rc = settingsRepo.rcSettings.value val rc = settingsRepo.rcSettings.value
if (rc.rotatorState) { if (rc.rotatorState) {