mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Added continuous SSTV decoding of manually selected type
This commit is contained in:
1 parent
39b786ae82
commit
e5033cb2e9
10 files changed
+327
-160
No files matched your search
@@ -47,11 +47,12 @@ class SstvDecoder(
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val frames: SharedFlow<SstvFrame> = _frames
|
||||
val supportedModes: List<String> = buildList {
|
||||
add("Raw"); add("HF Fax"); decoder.allModes.mapTo(this) { it.name }
|
||||
}
|
||||
val supportedModes: List<String> = 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<SstvMode>
|
||||
private val modes9ms: ArrayList<SstvMode>
|
||||
private val modes20ms: ArrayList<SstvMode>
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,7 +191,9 @@ 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 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,
|
||||
@@ -200,14 +204,13 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
||||
),
|
||||
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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SatRadio> = 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<SatRadio> = 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<Float, Float> = 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<SatRadio> = 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<String> = 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
|
||||
|
||||
@@ -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<List<OrbitalPos>>(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<ShaderBrush?>(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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 pass = findCurrentPass() ?: return@launch
|
||||
val allRadios = loadPassData(pass)
|
||||
while (isActive) {
|
||||
tickPass(pass, allRadios)
|
||||
delay(1000.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findCurrentPass(): OrbitalPass? {
|
||||
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))
|
||||
return passes.find { it.catNum == catNum && it.aosTime == aosTime }
|
||||
?: passes.firstOrNull()
|
||||
}
|
||||
if (transmittersList.isNotEmpty()) {
|
||||
val firstUuid = transmittersList.first().uuid
|
||||
_uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
|
||||
|
||||
// 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) }
|
||||
}
|
||||
while (isActive) {
|
||||
tickPass(pass, transmittersList)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
return allRadios
|
||||
}
|
||||
|
||||
private suspend fun tickPass(pass: OrbitalPass, transmittersList: List<SatRadio>) {
|
||||
// --- Per-second tick ---
|
||||
|
||||
private suspend fun tickPass(pass: OrbitalPass, allRadios: List<SatRadio>) {
|
||||
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 {
|
||||
|
||||
@@ -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,13 +182,28 @@ 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)
|
||||
) {
|
||||
// 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()
|
||||
) {
|
||||
// Mode button — opens dialog
|
||||
ElevatedCard(
|
||||
@@ -215,14 +232,13 @@ internal fun SstvPage(
|
||||
IconCard(
|
||||
action = { onAction(RadarAction.SstvSaveImage) },
|
||||
resId = R.drawable.ic_save,
|
||||
enabled = sstv.currentFrame?.imagePixels != null
|
||||
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)
|
||||
SstvStatus.Saving -> {}
|
||||
}
|
||||
}
|
||||
val playColors = CardDefaults.elevatedCardColors(
|
||||
@@ -237,4 +253,5 @@ internal fun SstvPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user