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
+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
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user