Added continuous SSTV decoding of manually selected type

This commit is contained in:
Arty Bishop committed 2026-06-06 19:20:35 +01:00
1 parent 39b786ae82
commit e5033cb2e9
10 files changed
+381 -214

No files matched your search

@@ -47,11 +47,12 @@ class SstvDecoder(
onBufferOverflow = BufferOverflow.DROP_OLDEST onBufferOverflow = BufferOverflow.DROP_OLDEST
) )
val frames: SharedFlow<SstvFrame> = _frames val frames: SharedFlow<SstvFrame> = _frames
val supportedModes: List<String> = buildList { val supportedModes: List<String> = decoder.allModes.map { it.name }
add("Raw"); add("HF Fax"); decoder.allModes.mapTo(this) { it.name }
}
suspend fun feedSamples(samples: FloatArray) = withContext(Dispatchers.Default) { 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) val hasNewLines = decoder.process(samples, channelSelect)
if (hasNewLines) emitFrame() if (hasNewLines) emitFrame()
} }
@@ -64,12 +65,31 @@ class SstvDecoder(
} }
private fun emitFrame() { private fun emitFrame() {
val imagePixels = if (imageBuffer.line > 0) imageBuffer.pixels.copyOf() else null
val imageWidth = imageBuffer.width val imageWidth = imageBuffer.width
val imageHeight = imageBuffer.height 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 val modeName = decoder.currentMode.name
_frames.tryEmit(SstvFrame(imagePixels, imageWidth, imageHeight, modeName)) _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 } internal enum class SyncPulseWidth { FiveMs, NineMs, TwentyMs }
@@ -136,6 +156,10 @@ internal class SyncPulseDetector(sampleRate: Int) {
fun process(buffer: FloatArray, channelSelect: Int): Boolean { fun process(buffer: FloatArray, channelSelect: Int): Boolean {
var detected = false var detected = false
val channels = if (channelSelect > 0) 2 else 1 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) { for (i in 0 until buffer.size / channels) {
when (channelSelect) { when (channelSelect) {
1 -> baseBand.set(buffer[2 * i]) 1 -> baseBand.set(buffer[2 * i])
@@ -200,7 +224,6 @@ internal class DecoderEngine(
private val visBitLen: Int private val visBitLen: Int
private val visLen: Int private val visLen: Int
private val rawMode: SstvMode private val rawMode: SstvMode
private val hfFaxMode: SstvMode
private val modes5ms: ArrayList<SstvMode> private val modes5ms: ArrayList<SstvMode>
private val modes9ms: ArrayList<SstvMode> private val modes9ms: ArrayList<SstvMode>
private val modes20ms: ArrayList<SstvMode> private val modes20ms: ArrayList<SstvMode>
@@ -217,6 +240,10 @@ internal class DecoderEngine(
init { init {
imageBuffer.line = -1 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 val pfLen = round(0.0025 * sampleRate).toInt() or 1
pulseFilterDelay = (pfLen - 1) / 2 pulseFilterDelay = (pfLen - 1) / 2
pulseFilter = MovingAverage(pfLen) pulseFilter = MovingAverage(pfLen)
@@ -231,7 +258,6 @@ internal class DecoderEngine(
syncTolerance = round(0.03 * sampleRate).toInt() syncTolerance = round(0.03 * sampleRate).toInt()
lineTolerance = round(0.001 * sampleRate).toInt() lineTolerance = round(0.001 * sampleRate).toInt()
rawMode = RawMode(rawName, sampleRate) rawMode = RawMode(rawName, sampleRate)
hfFaxMode = HfFaxMode(sampleRate)
val robot36 = Robot36Mode(sampleRate) val robot36 = Robot36Mode(sampleRate)
currentMode = robot36 currentMode = robot36
curLineSamples = robot36.scanLineSamples curLineSamples = robot36.scanLineSamples
@@ -301,11 +327,7 @@ internal class DecoderEngine(
} }
fun setMode(name: String) { fun setMode(name: String) {
if (rawMode.name == name) { val mode = allModes.firstOrNull { it.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
if (mode == currentMode) { if (mode == currentMode) {
lockMode = true; return lockMode = true; return
} }
@@ -335,6 +357,10 @@ internal class DecoderEngine(
return best 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() { private fun copyUnscaled() {
val w = minOf(scopeBuffer.width, pixelBuffer.width) val w = minOf(scopeBuffer.width, pixelBuffer.width)
for (row in 0 until pixelBuffer.height) { for (row in 0 until pixelBuffer.height) {
@@ -424,6 +450,9 @@ internal class DecoderEngine(
if ((amount <= 0) || (amount > sample)) return if ((amount <= 0) || (amount > sample)) return
sample -= amount; leaderBreak -= amount; lastSync -= amount sample -= amount; leaderBreak -= amount; lastSync -= amount
adjust(sync5ms, amount); adjust(sync9ms, amount); adjust(sync20ms, 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) scanLineBuffer.copyInto(scanLineBuffer, 0, amount, amount + sample)
} }
@@ -494,7 +523,7 @@ internal class DecoderEngine(
if (lockMode && mode != currentMode) return false if (lockMode && mode != currentMode) return false
mode.resetState() mode.resetState()
imageBuffer.width = mode.width; imageBuffer.height = mode.height 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 currentMode = mode
lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset lastSync = sIdx + mode.firstSyncPulseIndex; curLineSamples = mode.scanLineSamples; lastOffset = ldrOffset
var oldest = lastSync - (pulses.size - 1) * curLineSamples var oldest = lastSync - (pulses.size - 1) * curLineSamples
@@ -527,6 +556,15 @@ internal class DecoderEngine(
var changed = false var changed = false
if (lockMode || imageBuffer.line in 0 until imageBuffer.height) { if (lockMode || imageBuffer.line in 0 until imageBuffer.height) {
if (currentMode != rawMode && abs(lineSamples - currentMode.scanLineSamples) > lineTolerance) return false 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 { } else {
val prev = currentMode; currentMode = detectMode(modes, lineSamples) val prev = currentMode; currentMode = detectMode(modes, lineSamples)
changed = changed =
@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.core.domain.sstv package com.rtbishop.look4sat.core.domain.sstv
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.atan2
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.round 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 set(real: Float): Complex = set(real, 0f)
fun abs(): Float = sqrt(real * real + imag * imag) fun abs(): Float = sqrt(real * real + imag * imag)
fun arg(): Float = atan2(imag, real)
fun mul(other: Complex): Complex { fun mul(other: Complex): Complex {
val tmp = real * other.real - imag * other.imag 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) { internal open class MovingSum(val length: Int) {
private val tree = FloatArray(2 * length) private val buf = FloatArray(length)
private var leaf = length private var pos = 0
private var runningSum = 0f
fun add(input: Float) { fun add(input: Float) {
tree[leaf] = input runningSum += input - buf[pos]
var child = leaf buf[pos] = input
var parent = leaf / 2 if (++pos >= length) pos = 0
while (parent > 0) {
tree[parent] = tree[child] + tree[child xor 1]; child = parent; parent /= 2
}
if (++leaf >= tree.size) leaf = length
} }
fun sum(): Float = tree[1] fun sum(): Float = runningSum
fun sum(input: Float): Float { fun sum(input: Float): Float {
add(input); return sum() add(input); return sum()
} }
@@ -134,8 +132,15 @@ internal class Phasor(freq: Double, rate: Double) {
val omega = 2 * PI * freq / rate val omega = 2 * PI * freq / rate
Complex(cos(omega).toFloat(), sin(omega).toFloat()) 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) { internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
@@ -145,11 +150,30 @@ internal class FmDemodulator(bandwidth: Double, sampleRate: Double) {
private var prev = 0f private var prev = 0f
fun demodulate(input: Complex): Float { 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 var delta = phase - prev; prev = phase
if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi if (delta < -pi) delta += twoPi else if (delta > pi) delta -= twoPi
return scale * delta 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) { internal class ComplexFirFilter(val length: Int) {
@@ -257,9 +257,16 @@ internal class Robot36Mode(sampleRate: Int) : SstvMode {
val lPos = lumBegin + (i * lumSamples) / width val lPos = lumBegin + (i * lumSamples) / width
val cPos = chromBegin + (i * chromSamples) / width val cPos = chromBegin + (i * chromSamples) / width
if (even) { 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]) pixelBuffer.pixels[i] = ColorConverter.rgb(scratch[lPos], 0f, scratch[cPos])
} else { } else {
val evenYuv = pixelBuffer.pixels[i] 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) val oddYuv = ColorConverter.rgb(scratch[lPos], scratch[cPos], 0f)
pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00)) pixelBuffer.pixels[i] = ColorConverter.yuv2rgb((evenYuv and 0x00ff00ff) or (oddYuv and 0x0000ff00))
pixelBuffer.pixels[i + width] = 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 { internal class RawMode(override val name: String, sampleRate: Int) : SstvMode {
override val visCode = -1 override val visCode = -1
override val width = -1 override val width = -1
@@ -40,6 +40,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedButton import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -49,12 +50,21 @@ 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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@@ -387,3 +397,76 @@ fun elevationColor(elevation: Double): Color {
else -> Color(0xFF66BB6A) // soft green for high elevation 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)
}
@@ -66,6 +66,7 @@ import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.TimerRow import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar 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.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
@@ -170,13 +171,14 @@ private fun PagerCard(
) { pageIndex -> ) { pageIndex ->
when (pages[pageIndex]) { when (pages[pageIndex]) {
RadarPage.Transceivers -> TransceiversPage( RadarPage.Transceivers -> TransceiversPage(
transceivers = uiState.transmitters, transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.selectedTransmitterUuid, selectedUuid = uiState.transceivers.selectedUuid,
radioControl = uiState.radioControl, radioControl = uiState.radioControl,
onAction = onAction onAction = onAction
) )
RadarPage.Sstv -> SstvPage( RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv, sstv = uiState.sstv,
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
onAction = onAction, onAction = onAction,
requestMicPermission = requestMicPermission requestMicPermission = requestMicPermission
) )
@@ -189,25 +191,26 @@ private fun PagerCard(
@Composable @Composable
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) { private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
val satellitePos = uiState.orbitalPos val satellitePos = uiState.orbitalPos
val borderModifier = if (satellitePos?.aboveHorizon == true && satellitePos.eclipsed) { val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder") // Always call these composables unconditionally — conditional composable calls violate
val borderAlpha by infiniteTransition.animateFloat( // Compose's slot-table stability rules and can crash or produce incorrect state
initialValue = 1.0f, val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
targetValue = 0.0f, val borderAlpha by infiniteTransition.animateFloat(
animationSpec = infiniteRepeatable( initialValue = 1.0f,
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing), targetValue = 0.0f,
repeatMode = RepeatMode.Reverse animationSpec = infiniteRepeatable(
), animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
label = "eclipsedBorderAlpha" repeatMode = RepeatMode.Reverse
) ),
label = "eclipsedBorderAlpha"
)
val borderModifier = if (shouldAnimateBorder) {
Modifier.border( Modifier.border(
width = 0.5.dp, width = 0.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha), color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) )
} else { } else Modifier
Modifier
}
ElevatedCard(modifier = modifier.then(borderModifier)) { ElevatedCard(modifier = modifier.then(borderModifier)) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
val position = uiState.orbitalPos val position = uiState.orbitalPos
@@ -294,4 +297,3 @@ private fun RadarLabel(
} }
} }
} }
@@ -34,7 +34,6 @@ data class RadioPanelState(
data class RadioControlSubState( data class RadioControlSubState(
val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"), val txPanel: RadioPanelState = RadioPanelState("TX (Uplink)"),
val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"), val rxPanel: RadioPanelState = RadioPanelState("RX (Downlink)"),
val transponders: List<SatRadio> = emptyList(),
val selectedTransponderUuid: String? = null, val selectedTransponderUuid: String? = null,
val txBaseFrequencyHz: Long? = null, val txBaseFrequencyHz: Long? = null,
val ctcssTone: Double? = null, val ctcssTone: Double? = null,
@@ -42,11 +41,16 @@ data class RadioControlSubState(
val errorMessage: String? = null val errorMessage: String? = null
) )
data class TransceiverSubState(
val transmitters: List<SatRadio> = emptyList(),
val selectedUuid: String? = null,
val selectedFrequency: Long? = null,
)
data class RadarState( data class RadarState(
val currentPass: OrbitalPass? = null, val currentPass: OrbitalPass? = null,
val currentTime: String = "00:00:00", val currentTime: String = "00:00:00",
val isTimeAos: Boolean = true, val isTimeAos: Boolean = true,
val isLos: Boolean = false,
val isUtc: Boolean = false, val isUtc: Boolean = false,
val orientationValues: Pair<Float, Float> = 0f to 0f, val orientationValues: Pair<Float, Float> = 0f to 0f,
val orbitalPos: OrbitalPos? = null, val orbitalPos: OrbitalPos? = null,
@@ -55,17 +59,16 @@ data class RadarState(
val shouldUseCompass: Boolean = false, val shouldUseCompass: Boolean = false,
val sunPosition: CelestialComputer.SunPosition? = null, val sunPosition: CelestialComputer.SunPosition? = null,
val moonPosition: CelestialComputer.MoonPosition? = null, val moonPosition: CelestialComputer.MoonPosition? = null,
val transmitters: List<SatRadio> = emptyList(), val transceivers: TransceiverSubState = TransceiverSubState(),
val selectedTransmitterUuid: String? = null,
val selectedFrequency: Long? = null,
val radioControl: RadioControlSubState = RadioControlSubState(), val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState() val sstv: SstvSubState = SstvSubState()
) )
enum class SstvStatus { Idle, Recording, Saving } enum class SstvStatus { Idle, Recording }
data class SstvSubState( data class SstvSubState(
val status: SstvStatus = SstvStatus.Idle, val status: SstvStatus = SstvStatus.Idle,
val isSaving: Boolean = false,
val hasPermission: Boolean = false, val hasPermission: Boolean = false,
val selectedMode: String = "Auto", val selectedMode: String = "Auto",
val supportedModes: List<String> = emptyList(), val supportedModes: List<String> = emptyList(),
@@ -77,7 +80,6 @@ sealed interface RadarAction {
data class SelectTransmitter(val uuid: String) : RadarAction data class SelectTransmitter(val uuid: String) : RadarAction
// Radio control actions // Radio control actions
data class SelectTransponder(val uuid: String) : RadarAction
data class SetTxFrequency(val frequencyHz: Long) : RadarAction data class SetTxFrequency(val frequencyHz: Long) : RadarAction
data class AdjustTxFrequency(val deltaHz: Long) : RadarAction data class AdjustTxFrequency(val deltaHz: Long) : RadarAction
data class SetCtcssTone(val toneHz: Double?) : RadarAction data class SetCtcssTone(val toneHz: Double?) : RadarAction
@@ -17,6 +17,7 @@
*/ */
package com.rtbishop.look4sat.feature.radar package com.rtbishop.look4sat.feature.radar
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
@@ -64,7 +65,7 @@ import kotlin.math.sin
private const val CIRCLES = 3 private const val CIRCLES = 3
private const val STROKE_WIDTH = 6f private const val STROKE_WIDTH = 6f
private const val SWEEP_INCREMENT = 360f / 12f / 60f private const val SWEEP_DURATION_MS = 8_000
@Composable @Composable
fun RadarViewCompose( fun RadarViewCompose(
@@ -88,23 +89,44 @@ fun RadarViewCompose(
animationSpec = infiniteRepeatable(tween(1000)), animationSpec = infiniteRepeatable(tween(1000)),
label = "animScale" 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 measurer = rememberTextMeasurer()
val sunPainter = painterResource(R.drawable.ic_sun) val sunPainter = painterResource(R.drawable.ic_sun)
val moonPainter = painterResource(R.drawable.ic_moon) 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 cachedRadius by remember { mutableFloatStateOf(0f) }
var cachedItemsRef by remember { mutableStateOf<List<OrbitalPos>>(emptyList()) }
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
var trackPath by remember { mutableStateOf(Path()) } var trackPath by remember { mutableStateOf(Path()) }
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) } 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<ShaderBrush?>(null) }
Canvas(modifier = modifier.aspectRatio(1f)) { Canvas(modifier = modifier.aspectRatio(1f)) {
val radius = size.minDimension / 2f * 0.95f 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) trackPath = createTrackPath(items, radius)
trackEffect = createTrackEffect(trackPath) trackEffect = createTrackEffect(trackPath)
cachedSweepBrush = makeSweepBrush(center, primaryColor)
cachedRadius = radius 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) { 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) drawRadar(radius, radarColor)
drawElevationLabels(radius, primaryColor, measurer) drawElevationLabels(radius, primaryColor, measurer)
translate(center.x, center.y) { translate(center.x, center.y) {
@@ -127,7 +149,6 @@ fun RadarViewCompose(
} }
if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor) 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)) 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 colors = listOf(Color.Transparent, color.copy(alpha = 0.5f), color)
val colorStops = listOf(0.64f, 0.995f, 1f) 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) } rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
} }
@@ -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.round
import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.formatFrequency
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -48,7 +49,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale import kotlin.time.Duration.Companion.milliseconds
class RadarViewModel( class RadarViewModel(
private val bluetoothReporter: IReporter, private val bluetoothReporter: IReporter,
@@ -69,6 +70,11 @@ class RadarViewModel(
private var sstvDecoder: SstvDecoder? = null private var sstvDecoder: SstvDecoder? = null
private var sstvRecordingJob: Job? = 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( private val _uiState = MutableStateFlow(
RadarState( RadarState(
isUtc = settingsRepo.otherSettings.value.stateOfUtc, isUtc = settingsRepo.otherSettings.value.stateOfUtc,
@@ -101,52 +107,75 @@ class RadarViewModel(
private fun collectSettingsChanges() { private fun collectSettingsChanges() {
viewModelScope.launch { viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings -> 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() { private fun collectPassAndStartTickLoop() {
viewModelScope.launch { viewModelScope.launch {
val passes = satelliteRepo.passes.value val pass = findCurrentPass() ?: return@launch
val (catNum, aosTime) = satelliteRepo.selectedPass.value val allRadios = loadPassData(pass)
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) }
}
while (isActive) { while (isActive) {
tickPass(pass, transmittersList) tickPass(pass, allRadios)
delay(1000) delay(1000.milliseconds)
} }
} }
} }
private suspend fun tickPass(pass: OrbitalPass, transmittersList: List<SatRadio>) { 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<SatRadio> {
_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<SatRadio>) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow) 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 (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
val isLos = !pass.isDeepSpace && timeNow > pass.losTime
_uiState.update { _uiState.update {
it.copy( it.copy(
currentTime = time, isTimeAos = isAos, isLos = isLos, currentTime = time, isTimeAos = isAos,
orbitalPos = pos, sunPosition = sunPos, moonPosition = moonPos orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
) )
} }
processRadios(transmittersList, pass.orbitalObject, timeNow) processRadios(allRadios, pass.orbitalObject, timeNow)
sendPassData(pos) sendPassData(pos)
} }
@@ -191,24 +220,15 @@ class RadarViewModel(
when (action) { when (action) {
is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime) is RadarAction.AddToCalendar -> addToCalendar(action.name, action.aosTime, action.losTime)
is RadarAction.SelectTransmitter -> { is RadarAction.SelectTransmitter -> {
val previousUuid = _uiState.value.selectedTransmitterUuid // Compute toggle state before the update so we don't read post-update value
_uiState.update { val isTogglingOff = _uiState.value.transceivers.selectedUuid == action.uuid
val newUuid = if (it.selectedTransmitterUuid == action.uuid) null else action.uuid val newUuid = if (isTogglingOff) null else action.uuid
it.copy(selectedTransmitterUuid = newUuid) _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.SetTxFrequency -> trackingService.setTxBaseFrequency(action.frequencyHz)
is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz) is RadarAction.AdjustTxFrequency -> trackingService.adjustTxBaseFrequency(action.deltaHz)
@@ -236,10 +256,10 @@ class RadarViewModel(
RadarAction.SstvSaveImage -> { RadarAction.SstvSaveImage -> {
val frame = _uiState.value.sstv.currentFrame ?: return val frame = _uiState.value.sstv.currentFrame ?: return
val pixels = frame.imagePixels ?: 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 { viewModelScope.launch {
saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName) 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") showToast("Image saved")
} }
} }
@@ -249,7 +269,7 @@ class RadarViewModel(
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) } settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
} }
RadarAction.SstvReset -> { RadarAction.SstvReset -> {
sstvDecoder?.clearPixels() // Keep decoder alive, just reset pixels sstvDecoder?.clearPixels()
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) } _uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
} }
} }
@@ -285,13 +305,14 @@ class RadarViewModel(
frequencyEnabled: Boolean, frequencyEnabled: Boolean,
frequencyFormat: String 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 azimuth = orbitalPos.azimuth.toDegrees().round(2)
val elevation = orbitalPos.elevation.toDegrees().round(2) val elevation = orbitalPos.elevation.toDegrees().round(2)
reporter.reportRotation(rotatorFormat, azimuth, elevation) reporter.reportRotation(rotatorFormat, azimuth, elevation)
} }
if (frequencyEnabled) { if (frequencyEnabled) {
_uiState.value.selectedFrequency?.let { freq -> _uiState.value.transceivers.selectedFrequency?.let { freq ->
reporter.reportFrequency(frequencyFormat, freq) reporter.reportFrequency(frequencyFormat, freq)
} }
} }
@@ -302,9 +323,8 @@ class RadarViewModel(
val isFreqEnabled = val isFreqEnabled =
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
_uiState.update { state -> _uiState.update { state ->
// Derive the frequency to report from the user's current selection (if any) val freq = if (isFreqEnabled && state.transceivers.selectedUuid != null) {
val freq = if (isFreqEnabled && state.selectedTransmitterUuid != null) { val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
val selectedRadio = transmitters.firstOrNull { it.uuid == state.selectedTransmitterUuid }
selectedRadio?.let { radio -> selectedRadio?.let { radio ->
val low = radio.downlinkLow val low = radio.downlinkLow
val high = radio.downlinkHigh val high = radio.downlinkHigh
@@ -316,10 +336,11 @@ class RadarViewModel(
} }
} else null } else null
if (state.transmitters == transmitters && state.selectedFrequency == freq) { val current = state.transceivers
if (current.transmitters == transmitters && current.selectedFrequency == freq) {
return@update state 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) { if (sstvDecoder == null) {
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate) val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
sstvDecoder = decoder sstvDecoder = decoder
decoder.lockMode(_uiState.value.sstv.selectedMode)
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) } _uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
viewModelScope.launch { viewModelScope.launch {
decoder.frames.collect { frame -> decoder.frames.collect { frame ->
@@ -362,13 +384,6 @@ class RadarViewModel(
218.1, 225.7, 233.6, 241.8, 250.3 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 { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
@@ -55,12 +55,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.rtbishop.look4sat.core.presentation.IconCard 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.R
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@Composable @Composable
internal fun SstvPage( internal fun SstvPage(
sstv: SstvSubState, sstv: SstvSubState,
dopplerFrequency: String?,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit requestMicPermission: () -> Unit
) { ) {
@@ -180,59 +182,74 @@ internal fun SstvPage(
) )
} }
// Bottom control bar — dark, blends with the black canvas // Bottom control bar — dark, blends with the black canvas
Row( Column(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp) .padding(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
// Mode button — opens dialog // Doppler-corrected downlink frequency hint
ElevatedCard( if (dopplerFrequency != null) {
modifier = Modifier.weight(1f).height(48.dp), OutlinedText(
onClick = { showModeDialog.value = true }, text = "RX: $dopplerFrequency Hz",
colors = CardDefaults.elevatedCardColors( fontSize = 18.sp,
containerColor = MaterialTheme.colorScheme.surface 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) { // Mode button — opens dialog
Text( ElevatedCard(
text = "Mode: ${sstv.selectedMode}", modifier = Modifier.weight(1f).height(48.dp),
fontSize = 14.sp, onClick = { showModeDialog.value = true },
maxLines = 1, colors = CardDefaults.elevatedCardColors(
color = MaterialTheme.colorScheme.onSurface, containerColor = MaterialTheme.colorScheme.surface
modifier = Modifier.infiniteMarquee()
) )
) {
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
// Reset button — clears decoder state and image IconCard(
IconCard( action = { onAction(RadarAction.SstvReset) },
action = { onAction(RadarAction.SstvReset) }, resId = R.drawable.ic_delete
resId = R.drawable.ic_delete )
) // Save button — always visible, enabled when there are pixels
// Save button — always visible, enabled when there are pixels IconCard(
IconCard( action = { onAction(RadarAction.SstvSaveImage) },
action = { onAction(RadarAction.SstvSaveImage) }, resId = R.drawable.ic_save,
resId = R.drawable.ic_save, enabled = sstv.currentFrame?.imagePixels != null && !sstv.isSaving
enabled = sstv.currentFrame?.imagePixels != null )
) // Record / Stop button
// Record / Stop button val playAction = {
val playAction = { when (sstv.status) {
when (sstv.status) { SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording) SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording) }
SstvStatus.Saving -> {}
} }
} val playColors = CardDefaults.elevatedCardColors(
val playColors = CardDefaults.elevatedCardColors( containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary
containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surface )
) val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play
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) {
ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Icon(painter = painterResource(playIcon), contentDescription = null)
Icon(painter = painterResource(playIcon), contentDescription = null) }
} }
} }
} }
@@ -62,8 +62,10 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.formatFrequency
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import java.util.Locale import java.util.Locale
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun TransceiversPage( fun TransceiversPage(
@@ -82,7 +84,7 @@ fun TransceiversPage(
if (selectedUuid != null) { if (selectedUuid != null) {
val index = transceivers.indexOfFirst { it.uuid == selectedUuid } val index = transceivers.indexOfFirst { it.uuid == selectedUuid }
if (index >= 0) { if (index >= 0) {
kotlinx.coroutines.delay(300) kotlinx.coroutines.delay(300.milliseconds)
listState.animateScrollToItem(index) listState.animateScrollToItem(index)
} }
} }
@@ -325,7 +327,7 @@ private fun ExpandedRadioControl(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text( Text(
text = "${RadarViewModel.formatFrequency(radioControl.txBaseFrequencyHz)} MHz", text = "${formatFrequency(radioControl.txBaseFrequencyHz)} MHz",
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
@@ -335,7 +337,7 @@ private fun ExpandedRadioControl(
val upHigh = radio.uplinkHigh val upHigh = radio.uplinkHigh
if (upLow != null && upHigh != null && upLow != upHigh) { if (upLow != null && upHigh != null && upLow != upHigh) {
Text( Text(
text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})", text = "(${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
fontSize = 12.sp, fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )