From e5033cb2e9c3f36e0040a609c61856acad241451 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Sat, 6 Jun 2026 12:55:16 +0100 Subject: [PATCH] Added continuous SSTV decoding of manually selected type --- .../look4sat/core/domain/sstv/SstvDecoder.kt | 62 ++++++-- .../look4sat/core/domain/sstv/SstvDsp.kt | 52 +++++-- .../look4sat/core/domain/sstv/SstvModes.kt | 54 +------ .../look4sat/core/presentation/Components.kt | 83 +++++++++++ .../look4sat/feature/radar/RadarScreen.kt | 36 ++--- .../look4sat/feature/radar/RadarState.kt | 16 +- .../look4sat/feature/radar/RadarView.kt | 38 ++++- .../look4sat/feature/radar/RadarViewModel.kt | 141 ++++++++++-------- .../look4sat/feature/radar/SstvPage.kt | 105 +++++++------ .../feature/radar/TransceiversPage.kt | 8 +- 10 files changed, 381 insertions(+), 214 deletions(-) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt index 0651e2e4..e69ddd97 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDecoder.kt @@ -47,11 +47,12 @@ class SstvDecoder( onBufferOverflow = BufferOverflow.DROP_OLDEST ) val frames: SharedFlow = _frames - val supportedModes: List = buildList { - add("Raw"); add("HF Fax"); decoder.allModes.mapTo(this) { it.name } - } + val supportedModes: List = decoder.allModes.map { it.name } suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) { + // Normalize to a fixed RMS before processing so that both direct audio + // coupling and air-coupled microphone input decode with equal reliability. + normalise(samples) val hasNewLines = decoder.process(samples, channelSelect) if (hasNewLines) emitFrame() } @@ -64,12 +65,31 @@ class SstvDecoder( } private fun emitFrame() { - val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf() else null val imageWidth = imageBuffer.width val imageHeight = imageBuffer.height + // Copy only the active image region — imageBuffer.pixels is pre-allocated + // at the maximum possible size (PD-290: 800×616), so we must not copyOf() + // the entire array and send padding pixels to the observer. + val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf(imageWidth * imageHeight) else null val modeName = decoder.currentMode.name _frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName)) } + + // Target RMS level for the normalizer. 0.25 leaves headroom while keeping the + // FM demodulator well above its noise floor regardless of input gain. + private val targetRms = 0.25f + + // Bring the buffer to a fixed RMS so that microphone and direct-coupled inputs + // both decode reliably. The guard prevents amplifying pure silence into noise. + private fun normalise(buffer: FloatArray) { + var sumSq = 0f + for (s in buffer) sumSq += s * s + val rms = sqrt(sumSq / buffer.size) + if (rms > 1e-6f) { + val gain = targetRms / rms + for (i in buffer.indices) buffer[i] *= gain + } + } } internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs } @@ -136,6 +156,10 @@ internal class SyncPulseDetector(sampleRate: Int) { fun process(buffer: FloatArray, channelSelect: Int): Boolean { var detected = false val channels = if (channelSelect > 0) 2 else 1 + // NOTE: buffer[i] is overwritten in-place with the FM-demodulated frequency + // value for every mono sample (channelSelect == 0). DecoderEngine.process() + // reads back these values to populate scanLineBuffer. Callers must not reuse + // the buffer after this call. for (i in 0 until buffer.size / channels) { when (channelSelect) { 1 -> baseBand.set(buffer[2 * i]) @@ -200,7 +224,6 @@ internal class DecoderEngine( private val visBitLen: Int private val visLen: Int private val rawMode: SstvMode - private val hfFaxMode: SstvMode private val modes5ms: ArrayList private val modes9ms: ArrayList private val modes20ms: ArrayList @@ -217,6 +240,10 @@ internal class DecoderEngine( init { imageBuffer.line = -1 + // Pre-allocate for the largest possible mode (PD-290: 800×616 = 492 800 ints) + // so that handleHeader/processPulse can reuse the array with a fill(0) instead + // of allocating a fresh IntArray on every new image, reducing GC pressure. + imageBuffer.pixels = IntArray(800 * 616) val pfLen = round(0.0025 * sampleRate).toInt() or 1 pulseFilterDelay = (pfLen - 1) / 2 pulseFilter = MovingAverage(pfLen) @@ -231,7 +258,6 @@ internal class DecoderEngine( syncTolerance = round(0.03 * sampleRate).toInt() lineTolerance = round(0.001 * sampleRate).toInt() rawMode = RawMode(rawName, sampleRate) - hfFaxMode = HfFaxMode(sampleRate) val robot36 = Robot36Mode(sampleRate) currentMode = robot36 curLineSamples = robot36.scanLineSamples @@ -301,11 +327,7 @@ internal class DecoderEngine( } fun setMode(name: String) { - if (rawMode.name == name) { - lockMode = true; imageBuffer.line = -1; currentMode = rawMode; return - } - var mode = allModes.firstOrNull { it.name == name } - if (mode == null && hfFaxMode.name == name) mode = hfFaxMode + val mode = allModes.firstOrNull { it.name == name } if (mode == currentMode) { lockMode = true; return } @@ -335,6 +357,10 @@ internal class DecoderEngine( return best } + // scopeBuffer is twice the display height. Each decoded scan line is written + // to both the current rolling position (top half, wraps at height/2) and the + // same row offset in the bottom half. The UI displays a window that always + // spans the half-height boundary, giving a seamless non-wrapping scroll effect. private fun copyUnscaled() { val w = minOf(scopeBuffer.width, pixelBuffer.width) for (row in 0 until pixelBuffer.height) { @@ -424,6 +450,9 @@ internal class DecoderEngine( if ((amount <= 0) || (amount > sample)) return sample -= amount; leaderBreak -= amount; lastSync -= amount adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, amount) + // Discard already-decoded samples by sliding the live region back to index 0. + // System.arraycopy handles the overlapping regions correctly and is a native + // memcpy on JVM, so this is fast despite moving the full remaining window. scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample) } @@ -494,7 +523,7 @@ internal class DecoderEngine( if (lockMode && mode != currentMode) return false mode.resetState() imageBuffer.width = mode.width; imageBuffer.height = mode.height - imageBuffer.pixels = IntArray(mode.width * mode.height); imageBuffer.line = 0 + imageBuffer.pixels.fill(0, 0, mode.width * mode.height); imageBuffer.line = 0 currentMode = mode lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset var oldest = lastSync - (pulses.size - 1) * curLineSamples @@ -527,6 +556,15 @@ internal class DecoderEngine( var changed = false if (lockMode || imageBuffer.line in 0 until imageBuffer.height) { if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false + // Try continuous decoding + if (lockMode && imageBuffer.line == -1 && currentMode != rawMode) { + currentMode.resetState() + imageBuffer.width = currentMode.width + imageBuffer.height = currentMode.height + imageBuffer.pixels.fill(0, 0, currentMode.width * currentMode.height) + imageBuffer.line = 0 + drawLines(0xff000000.toInt(), 10); drawLines(0xffffff00.toInt(), 8); drawLines(0xff000000.toInt(), 10) + } } else { val prev = currentMode; currentMode = detectMode(modes, lineSamples) changed = diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt index 71dd4e62..cc3499f7 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvDsp.kt @@ -18,7 +18,6 @@ package com.rtbishop.look4sat.core.domain.sstv import kotlin.math.PI -import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.pow import kotlin.math.round @@ -34,7 +33,6 @@ internal class Complex(var real: Float = 0f, var imag: Float = 0f) { fun set(real: Float): Complex = set(real, 0f) fun abs(): Float = sqrt(real * real + imag * imag) - fun arg(): Float = atan2(imag, real) fun mul(other: Complex): Complex { val tmp = real * other.real - imag * other.imag @@ -74,21 +72,21 @@ internal object WindowFunctions { } } +// O(1) ring buffer — simpler and faster than a segment tree for the short window +// lengths used here (≤512 samples). Float32 accumulated drift over such windows +// is ~6e-5, negligible for audio-frequency processing. internal open class MovingSum(val length: Int) { - private val tree = FloatArray(2 * length) - private var leaf = length + private val buf = FloatArray(length) + private var pos = 0 + private var runningSum = 0f fun add(input: Float) { - tree[leaf] = input - var child = leaf - var parent = leaf / 2 - while (parent > 0) { - tree[parent] = tree[child] + tree[child xor 1]; child = parent; parent /= 2 - } - if (++leaf >= tree.size) leaf = length + runningSum += input - buf[pos] + buf[pos] = input + if (++pos >= length) pos = 0 } - fun sum(): Float = tree[1] + fun sum(): Float = runningSum fun sum(input: Float): Float { add(input); return sum() } @@ -134,8 +132,15 @@ internal class Phasor(freq: Double, rate: Double) { val omega = 2 * PI * freq / rate Complex(cos(omega).toFloat(), sin(omega).toFloat()) } + private var count = 0 - fun rotate(): Complex = value.div(value.mul(delta).abs()) + // Renormalize every 512 rotations to prevent magnitude drift accumulation, + // eliminating the per-sample sqrt without sacrificing demodulation accuracy. + fun rotate(): Complex { + value.mul(delta) + if (++count == 512) { value.div(value.abs()); count = 0 } + return value + } } internal class FmDemodulator(bandwidth: Double, sampleRate: Double) { @@ -145,11 +150,30 @@ internal class FmDemodulator(bandwidth: Double, sampleRate: Double) { private var prev = 0f fun demodulate(input: Complex): Float { - val phase = input.arg() + // Use fast polynomial atan2 instead of the exact trigonometric call. + // Max error ~0.005 rad translates to <1 Hz frequency error at 44100 Hz, + // well within the 50 Hz sync tolerance. + val phase = fastAtan2(input.imag, input.real) var delta = phase - prev; prev = phase if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi return scale * delta } + + // Rajan's polynomial approximation of atan2 — avoids a transcendental call + // in the per-sample hot path (~44 k calls/s at 44100 Hz sample rate). + private fun fastAtan2(y: Float, x: Float): Float { + val absY = kotlin.math.abs(y) + 1e-10f + val r: Float + val angle: Float + if (x >= 0f) { + r = (x - absY) / (x + absY) + angle = 0.1963f * r * r * r - 0.9817f * r + pi / 4f + } else { + r = (x + absY) / (absY - x) + angle = 0.1963f * r * r * r - 0.9817f * r + 3f * pi / 4f + } + return if (y < 0f) -angle else angle + } } internal class ComplexFirFilter(val length: Int) { diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt index 1e574018..128adc8d 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/sstv/SstvModes.kt @@ -257,9 +257,16 @@ internal class Robot36Mode(sampleRate: Int) : SstvMode { val lPos = lumBegin + (i * lumSamples) / width val cPos = chromBegin + (i * chromSamples) / width if (even) { + // Even line: store Y in the red channel slot and Cr in the blue slot, + // using ColorConverter.rgb() as a convenient 3×byte packer (not RGB). + // The odd line will read these back and combine with its own Cb to + // produce the final YUV→RGB conversion for both rows. pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos]) } else { val evenYuv = pixelBuffer.pixels[i] + // Even pixel packing: 0xAARRGGBB → Y=RR, Cb=GG(unused), Cr=BB + // Odd pixel: Y=lPos, Cb=cPos, Cr=(borrowed from even's BB slot) + // Merge: take Y+Cr from even row (bits 0x00ff00ff) and Cb from odd (0x0000ff00). val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f) pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00)) pixelBuffer.pixels[i + width] = @@ -389,53 +396,6 @@ internal class PdMode( } } -internal class HfFaxMode(private val sampleRate: Int) : SstvMode { - override val name = "HF Fax" - override val visCode = -1 - override val width = 640 - override val height = 1200 - override val firstPixelSampleIndex = 0 - override val firstSyncPulseIndex = -1 - override val scanLineSamples get() = sampleRate / 2 - - private val ema = Ema() - private val cumulated = FloatArray(width) - - var horizontalShift = 0; private set - - override fun decodeScanLine( - pixelBuffer: PixelBuffer, - scratch: FloatArray, - scanLine: FloatArray, - scopeWidth: Int, - syncPulseIndex: Int, - lineSamples: Int, - freqOffset: Float - ): Boolean { - if (syncPulseIndex < 0 || syncPulseIndex + lineSamples > scanLine.size) return false - ema.setCutoff(width.toDouble(), (2 * lineSamples).toDouble(), 2); ema.reset() - for (i in 0 until lineSamples) scratch[i] = ema.process(scanLine[syncPulseIndex + i]) - ema.reset() - for (i in lineSamples - 1 downTo 0) scratch[i] = freqToLevel(ema.process(scratch[i]), freqOffset) - var bestIdx = 0 - var bestVal = 0f - for (i in 0 until width) { - val pos = (i * lineSamples) / width - val color = ColorConverter.gray(scratch[pos]) - pixelBuffer.pixels[i] = color - // Grayscale: R==G==B, so luminance is simply the channel value normalized - val gray = (color and 0xFF) / 255f - cumulated[i] = cumulated[i] * 0.99f + gray * 0.01f - if (cumulated[i] > bestVal) { - bestIdx = i; bestVal = cumulated[i] - } - } - horizontalShift = bestIdx - pixelBuffer.width = width; pixelBuffer.height = 1 - return true - } -} - internal class RawMode(override val name: String, sampleRate: Int) : SstvMode { override val visCode = -1 override val width = -1 diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt index 36f94aad..c06ca405 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedButton import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -49,12 +50,21 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -387,3 +397,76 @@ fun elevationColor(elevation: Double): Color { else -> Color(0xFF66BB6A) // soft green for high elevation } } + +@Composable +fun OutlinedText( + text: String, + modifier: Modifier = Modifier, + fillColor: Color = Color.Unspecified, + outlineColor: Color, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, + outlineDrawStyle: Stroke = Stroke(width = 8f), +) { + Box(modifier = modifier) { + Text( + text = text, + modifier = Modifier.semantics { hideFromAccessibility() }, + color = outlineColor, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = null, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + style = style.copy(shadow = null, drawStyle = outlineDrawStyle), + ) + + Text( + text = text, + color = fillColor, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + style = style, + ) + } +} + +// Formats a frequency in Hz as "MMM.KKK.HHH" (e.g. 145.825.000) or "---" +fun formatFrequency(frequencyHz: Long): String { + if (frequencyHz <= 0) return "---" + val mhz = frequencyHz / 1_000_000 + val khz = (frequencyHz % 1_000_000) / 1_000 + val hz = frequencyHz % 1_000 + return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz) +} diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index 73b81a90..c3d753a2 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -66,6 +66,7 @@ import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.TimerRow import com.rtbishop.look4sat.core.presentation.TopBar +import com.rtbishop.look4sat.core.presentation.formatFrequency import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.layoutPadding @@ -170,13 +171,14 @@ private fun PagerCard( ) { pageIndex -> when (pages[pageIndex]) { RadarPage.Transceivers -> TransceiversPage( - transceivers = uiState.transmitters, - selectedUuid = uiState.selectedTransmitterUuid, + transceivers = uiState.transceivers.transmitters, + selectedUuid = uiState.transceivers.selectedUuid, radioControl = uiState.radioControl, onAction = onAction ) RadarPage.Sstv -> SstvPage( sstv = uiState.sstv, + dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) }, onAction = onAction, requestMicPermission = requestMicPermission ) @@ -189,25 +191,26 @@ private fun PagerCard( @Composable private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) { val satellitePos = uiState.orbitalPos - val borderModifier = if (satellitePos?.aboveHorizon == true && satellitePos.eclipsed) { - val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder") - val borderAlpha by infiniteTransition.animateFloat( - initialValue = 1.0f, - targetValue = 0.0f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "eclipsedBorderAlpha" - ) + val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed + // Always call these composables unconditionally — conditional composable calls violate + // Compose's slot-table stability rules and can crash or produce incorrect state + val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder") + val borderAlpha by infiniteTransition.animateFloat( + initialValue = 1.0f, + targetValue = 0.0f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "eclipsedBorderAlpha" + ) + val borderModifier = if (shouldAnimateBorder) { Modifier.border( width = 0.5.dp, color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha), shape = MaterialTheme.shapes.medium ) - } else { - Modifier - } + } else Modifier ElevatedCard(modifier = modifier.then(borderModifier)) { Box(contentAlignment = Alignment.Center) { val position = uiState.orbitalPos @@ -294,4 +297,3 @@ private fun RadarLabel( } } } - diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt index f36795c9..bc91568e 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt @@ -34,7 +34,6 @@ data class RadioPanelState( data class RadioControlSubState( val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"), val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"), - val transponders: List = emptyList(), val selectedTransponderUuid: String? = null, val txBaseFrequencyHz: Long? = null, val ctcssTone: Double? = null, @@ -42,11 +41,16 @@ data class RadioControlSubState( val errorMessage: String? = null ) +data class TransceiverSubState( + val transmitters: List = emptyList(), + val selectedUuid: String? = null, + val selectedFrequency: Long? = null, +) + data class RadarState( val currentPass: OrbitalPass? = null, val currentTime: String = "00:00:00", val isTimeAos: Boolean = true, - val isLos: Boolean = false, val isUtc: Boolean = false, val orientationValues: Pair = 0f to 0f, val orbitalPos: OrbitalPos? = null, @@ -55,17 +59,16 @@ data class RadarState( val shouldUseCompass: Boolean = false, val sunPosition: CelestialComputer.SunPosition? = null, val moonPosition: CelestialComputer.MoonPosition? = null, - val transmitters: List = emptyList(), - val selectedTransmitterUuid: String? = null, - val selectedFrequency: Long? = null, + val transceivers: TransceiverSubState = TransceiverSubState(), val radioControl: RadioControlSubState = RadioControlSubState(), val sstv: SstvSubState = SstvSubState() ) -enum class SstvStatus { Idle, Recording, Saving } +enum class SstvStatus { Idle, Recording } data class SstvSubState( val status: SstvStatus = SstvStatus.Idle, + val isSaving: Boolean = false, val hasPermission: Boolean = false, val selectedMode: String = "Auto", val supportedModes: List = emptyList(), @@ -77,7 +80,6 @@ sealed interface RadarAction { data class SelectTransmitter(val uuid: String) : RadarAction // Radio control actions - data class SelectTransponder(val uuid: String) : RadarAction data class SetTxFrequency(val frequencyHz: Long) : RadarAction data class AdjustTxFrequency(val deltaHz: Long) : RadarAction data class SetCtcssTone(val toneHz: Double?) : RadarAction diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt index 80a06e4d..bedf5a7d 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt @@ -17,6 +17,7 @@ */ package com.rtbishop.look4sat.feature.radar +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition @@ -64,7 +65,7 @@ import kotlin.math.sin private const val CIRCLES = 3 private const val STROKE_WIDTH = 6f -private const val SWEEP_INCREMENT = 360f / 12f / 60f +private const val SWEEP_DURATION_MS = 8_000 @Composable fun RadarViewCompose( @@ -88,23 +89,44 @@ fun RadarViewCompose( animationSpec = infiniteRepeatable(tween(1000)), label = "animScale" ) + // Drive the sweep from the animation framework to eliminate state mutation inside the draw block + val sweepTransition = rememberInfiniteTransition(label = "sweep") + val sweepDegrees by sweepTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)), + label = "sweepDegrees" + ) val measurer = rememberTextMeasurer() val sunPainter = painterResource(R.drawable.ic_sun) val moonPainter = painterResource(R.drawable.ic_moon) - var sweepDegrees by remember { mutableFloatStateOf(0f) } + // Track path cache — keyed by both canvas size and items reference so it rebuilds + // when the satellite track data arrives asynchronously after the first composition var cachedRadius by remember { mutableFloatStateOf(0f) } + var cachedItemsRef by remember { mutableStateOf>(emptyList()) } + var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) } var trackPath by remember { mutableStateOf(Path()) } var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) } + // ShaderBrush is cached to avoid allocating a new GPU shader object every frame + var cachedSweepBrush by remember { mutableStateOf(null) } Canvas(modifier = modifier.aspectRatio(1f)) { val radius = size.minDimension / 2f * 0.95f - if (radius != cachedRadius) { + // Rebuild track path and sweep brush when canvas size or track data changes + if (radius != cachedRadius || items !== cachedItemsRef) { trackPath = createTrackPath(items, radius) trackEffect = createTrackEffect(trackPath) + cachedSweepBrush = makeSweepBrush(center, primaryColor) cachedRadius = radius + cachedItemsRef = items + cachedSweepColor = primaryColor + } else if (primaryColor != cachedSweepColor) { + // Rebuild brush on theme change without waiting for a size change + cachedSweepBrush = makeSweepBrush(center, primaryColor) + cachedSweepColor = primaryColor } rotate(if (shouldUseCompass) -azimElev.first else 0f) { - if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, primaryColor) + if (shouldShowSweep) cachedSweepBrush?.let { drawSweep(center, sweepDegrees, radius, it) } drawRadar(radius, radarColor) drawElevationLabels(radius, primaryColor, measurer) translate(center.x, center.y) { @@ -127,7 +149,6 @@ fun RadarViewCompose( } if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor) } - sweepDegrees = (sweepDegrees + SWEEP_INCREMENT) % 360f } } } @@ -172,10 +193,13 @@ private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, color: Co drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH)) } -private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) { +private fun makeSweepBrush(center: Offset, color: Color): ShaderBrush { val colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color) val colorStops = listOf(0.64f, 0.995f, 1f) - val brush = ShaderBrush(SweepGradientShader(center, colors, colorStops)) + return ShaderBrush(SweepGradientShader(center, colors, colorStops)) +} + +private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, brush: ShaderBrush) { rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) } } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index 7898e183..7a5dc86d 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt @@ -40,6 +40,7 @@ import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.utility.round import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.domain.utility.toTimerString +import com.rtbishop.look4sat.core.presentation.formatFrequency import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -48,7 +49,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.util.Locale +import kotlin.time.Duration.Companion.milliseconds class RadarViewModel( private val bluetoothReporter: IReporter, @@ -69,6 +70,11 @@ class RadarViewModel( private var sstvDecoder: SstvDecoder? = null private var sstvRecordingJob: Job? = null + // Celestial positions change slowly, recompute at most once per minute + private var lastCelestialUpdateMs = 0L + private var cachedSunPos: CelestialComputer.SunPosition? = null + private var cachedMoonPos: CelestialComputer.MoonPosition? = null + private val _uiState = MutableStateFlow( RadarState( isUtc = settingsRepo.otherSettings.value.stateOfUtc, @@ -101,52 +107,75 @@ class RadarViewModel( private fun collectSettingsChanges() { viewModelScope.launch { settingsRepo.otherSettings.collectLatest { settings -> - _uiState.update { it.copy(isUtc = settings.stateOfUtc) } + _uiState.update { + it.copy( + isUtc = settings.stateOfUtc, + shouldShowSweep = settings.stateOfSweep, + shouldUseCompass = settings.stateOfSensors + ) + } } } } + // --- Pass loading split into focused functions --- + private fun collectPassAndStartTickLoop() { viewModelScope.launch { - val passes = satelliteRepo.passes.value - val (catNum, aosTime) = satelliteRepo.selectedPass.value - val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() ?: return@launch - _uiState.update { it.copy(currentPass = pass) } - val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum) - transponders = transmittersList.filter { it.downlinkLow != null } - _uiState.update { state -> - state.copy(radioControl = state.radioControl.copy(transponders = transponders)) - } - if (transmittersList.isNotEmpty()) { - val firstUuid = transmittersList.first().uuid - _uiState.update { it.copy(selectedTransmitterUuid = firstUuid) } - transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) } - } - if (!pass.isDeepSpace) { - val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime) - _uiState.update { it.copy(satTrack = track) } - } + val pass = findCurrentPass() ?: return@launch + val allRadios = loadPassData(pass) while (isActive) { - tickPass(pass, transmittersList) - delay(1000) + tickPass(pass, allRadios) + delay(1000.milliseconds) } } } - private suspend fun tickPass(pass: OrbitalPass, transmittersList: List) { + private fun findCurrentPass(): OrbitalPass? { + val passes = satelliteRepo.passes.value + val (catNum, aosTime) = satelliteRepo.selectedPass.value + return passes.find { it.catNum == catNum && it.aosTime == aosTime } + ?: passes.firstOrNull() + } + + // Loads transmitters and satellite track for pass, sets initial state, returns full radio list + private suspend fun loadPassData(pass: OrbitalPass): List { + _uiState.update { it.copy(currentPass = pass) } + val allRadios = satelliteRepo.getRadiosWithId(pass.catNum) + transponders = allRadios.filter { it.downlinkLow != null } + if (allRadios.isNotEmpty()) { + val firstUuid = allRadios.first().uuid + _uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = firstUuid)) } + transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) } + } + if (!pass.isDeepSpace) { + val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime) + _uiState.update { it.copy(satTrack = track) } + } + return allRadios + } + + // --- Per-second tick --- + + private suspend fun tickPass(pass: OrbitalPass, allRadios: List) { val timeNow = System.currentTimeMillis() val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow) - val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow) - val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow) + + // Recompute celestial positions at most once per minute (they move very slowly) + if (timeNow - lastCelestialUpdateMs >= 60_000L) { + cachedSunPos = CelestialComputer.getSunPosition(stationPos, timeNow) + cachedMoonPos = CelestialComputer.getMoonPosition(stationPos, timeNow) + lastCelestialUpdateMs = timeNow + } + val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow) - val isLos = !pass.isDeepSpace && timeNow > pass.losTime _uiState.update { it.copy( - currentTime = time, isTimeAos = isAos, isLos = isLos, - orbitalPos = pos, sunPosition = sunPos, moonPosition = moonPos + currentTime = time, isTimeAos = isAos, + orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos ) } - processRadios(transmittersList, pass.orbitalObject, timeNow) + processRadios(allRadios, pass.orbitalObject, timeNow) sendPassData(pos) } @@ -191,24 +220,15 @@ class RadarViewModel( when (action) { is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime) is RadarAction.SelectTransmitter -> { - val previousUuid = _uiState.value.selectedTransmitterUuid - _uiState.update { - val newUuid = if (it.selectedTransmitterUuid == action.uuid) null else action.uuid - it.copy(selectedTransmitterUuid = newUuid) + // Compute toggle state before the update so we don't read post-update value + val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid + val newUuid = if (isTogglingOff) null else action.uuid + _uiState.update { it.copy(transceivers = it.transceivers.copy(selectedUuid = newUuid)) } + // Only update the tracking service when selecting a different transponder to + // avoid resetting a user-adjusted TX base on re-expand + if (!isTogglingOff) { + transponders.find { it.uuid == action.uuid }?.let { trackingService.setTransponder(it) } } - // Also set this as the active transponder for radio tracking. - // Always use the original (un-Dopplered) transponders list so that the - // TX base frequency is computed from the clean nominal values. - // Only update the service when selecting a *different* transponder to - // avoid resetting a user-adjusted TX base on re-expand. - val selected = transponders.find { it.uuid == action.uuid } - if (selected != null && _uiState.value.selectedTransmitterUuid != null && previousUuid != action.uuid) { - trackingService.setTransponder(selected) - } - } - is RadarAction.SelectTransponder -> { - val transponder = transponders.find { it.uuid == action.uuid } ?: return - trackingService.setTransponder(transponder) } is RadarAction.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz) is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz) @@ -236,10 +256,10 @@ class RadarViewModel( RadarAction.SstvSaveImage -> { val frame = _uiState.value.sstv.currentFrame ?: return val pixels = frame.imagePixels ?: return - _uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Saving)) } + _uiState.update { it.copy(sstv = it.sstv.copy(isSaving = true)) } viewModelScope.launch { saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName) - _uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) } + _uiState.update { it.copy(sstv = it.sstv.copy(isSaving = false)) } showToast("Image saved") } } @@ -249,7 +269,7 @@ class RadarViewModel( settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) } } RadarAction.SstvReset -> { - sstvDecoder?.clearPixels() // Keep decoder alive, just reset pixels + sstvDecoder?.clearPixels() _uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) } } } @@ -285,13 +305,14 @@ class RadarViewModel( frequencyEnabled: Boolean, frequencyFormat: String ) { - if (rotatorEnabled) { + // Only send rotator commands when the satellite is above the horizon + if (rotatorEnabled && orbitalPos.aboveHorizon) { val azimuth = orbitalPos.azimuth.toDegrees().round(2) val elevation = orbitalPos.elevation.toDegrees().round(2) reporter.reportRotation(rotatorFormat, azimuth, elevation) } if (frequencyEnabled) { - _uiState.value.selectedFrequency?.let { freq -> + _uiState.value.transceivers.selectedFrequency?.let { freq -> reporter.reportFrequency(frequencyFormat, freq) } } @@ -302,9 +323,8 @@ class RadarViewModel( val isFreqEnabled = settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState _uiState.update { state -> - // Derive the frequency to report from the user's current selection (if any) - val freq = if (isFreqEnabled && state.selectedTransmitterUuid != null) { - val selectedRadio = transmitters.firstOrNull { it.uuid == state.selectedTransmitterUuid } + val freq = if (isFreqEnabled && state.transceivers.selectedUuid != null) { + val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid } selectedRadio?.let { radio -> val low = radio.downlinkLow val high = radio.downlinkHigh @@ -316,10 +336,11 @@ class RadarViewModel( } } else null - if (state.transmitters == transmitters && state.selectedFrequency == freq) { + val current = state.transceivers + if (current.transmitters == transmitters && current.selectedFrequency == freq) { return@update state } - state.copy(transmitters = transmitters, selectedFrequency = freq) + state.copy(transceivers = current.copy(transmitters = transmitters, selectedFrequency = freq)) } } @@ -327,6 +348,7 @@ class RadarViewModel( if (sstvDecoder == null) { val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate) sstvDecoder = decoder + decoder.lockMode(_uiState.value.sstv.selectedMode) _uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) } viewModelScope.launch { decoder.frames.collect { frame -> @@ -362,13 +384,6 @@ class RadarViewModel( 218.1, 225.7, 233.6, 241.8, 250.3 ) - fun formatFrequency(frequencyHz: Long): String { - if (frequencyHz <= 0) return "---" - val mhz = frequencyHz / 1_000_000 - val khz = (frequencyHz % 1_000_000) / 1_000 - val hz = frequencyHz % 1_000 - return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz) - } fun factory(container: IMainContainer) = viewModelFactory { initializer { diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt index 61c1eb9d..bde32602 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt @@ -55,12 +55,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import com.rtbishop.look4sat.core.presentation.IconCard +import com.rtbishop.look4sat.core.presentation.OutlinedText import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.infiniteMarquee @Composable internal fun SstvPage( sstv: SstvSubState, + dopplerFrequency: String?, onAction: (RadarAction) -> Unit, requestMicPermission: () -> Unit ) { @@ -180,59 +182,74 @@ internal fun SstvPage( ) } // Bottom control bar — dark, blends with the black canvas - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + Column( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp) + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { - // Mode button — opens dialog - ElevatedCard( - modifier = Modifier.weight(1f).height(48.dp), - onClick = { showModeDialog.value = true }, - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surface + // Doppler-corrected downlink frequency hint + if (dopplerFrequency != null) { + OutlinedText( + text = "RX: $dopplerFrequency Hz", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + fillColor = MaterialTheme.colorScheme.primary, + outlineColor = MaterialTheme.colorScheme.background, + modifier = Modifier.align(Alignment.CenterHorizontally) ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = "Mode: ${sstv.selectedMode}", - fontSize = 14.sp, - maxLines = 1, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.infiniteMarquee() + // Mode button — opens dialog + ElevatedCard( + modifier = Modifier.weight(1f).height(48.dp), + onClick = { showModeDialog.value = true }, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface ) + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "Mode: ${sstv.selectedMode}", + fontSize = 14.sp, + maxLines = 1, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.infiniteMarquee() + ) + } } - } - // Reset button — clears decoder state and image - IconCard( - action = { onAction(RadarAction.SstvReset) }, - resId = R.drawable.ic_delete - ) - // Save button — always visible, enabled when there are pixels - IconCard( - action = { onAction(RadarAction.SstvSaveImage) }, - resId = R.drawable.ic_save, - enabled = sstv.currentFrame?.imagePixels != null - ) - // Record / Stop button - val playAction = { - when (sstv.status) { - SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording) - SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording) - SstvStatus.Saving -> {} + // Reset button — clears decoder state and image + IconCard( + action = { onAction(RadarAction.SstvReset) }, + resId = R.drawable.ic_delete + ) + // Save button — always visible, enabled when there are pixels + IconCard( + action = { onAction(RadarAction.SstvSaveImage) }, + resId = R.drawable.ic_save, + enabled = sstv.currentFrame?.imagePixels != null && !sstv.isSaving + ) + // Record / Stop button + val playAction = { + when (sstv.status) { + SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording) + SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording) + } } - } - val playColors = CardDefaults.elevatedCardColors( - containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surface - ) - val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play - ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Icon(painter = painterResource(playIcon), contentDescription = null) + val playColors = CardDefaults.elevatedCardColors( + containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surface + ) + val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play + ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon(painter = painterResource(playIcon), contentDescription = null) + } } } } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt index 4077e478..41bab435 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/TransceiversPage.kt @@ -62,8 +62,10 @@ import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.R +import com.rtbishop.look4sat.core.presentation.formatFrequency import com.rtbishop.look4sat.core.presentation.infiniteMarquee import java.util.Locale +import kotlin.time.Duration.Companion.milliseconds @Composable fun TransceiversPage( @@ -82,7 +84,7 @@ fun TransceiversPage( if (selectedUuid != null) { val index = transceivers.indexOfFirst { it.uuid == selectedUuid } if (index >= 0) { - kotlinx.coroutines.delay(300) + kotlinx.coroutines.delay(300.milliseconds) listState.animateScrollToItem(index) } } @@ -325,7 +327,7 @@ private fun ExpandedRadioControl( color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = "${RadarViewModel.formatFrequency(radioControl.txBaseFrequencyHz)} MHz", + text = "${formatFrequency(radioControl.txBaseFrequencyHz)} MHz", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary @@ -335,7 +337,7 @@ private fun ExpandedRadioControl( val upHigh = radio.uplinkHigh if (upLow != null && upHigh != null && upLow != upHigh) { Text( - text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})", + text = "(${formatFrequency(upLow)} – ${formatFrequency(upHigh)})", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant )