Added continuous SSTV decoding of manually selected type

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

No files matched your search

@@ -66,6 +66,7 @@ import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.formatFrequency
import com.rtbishop.look4sat.core.presentation.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
@@ -170,13 +171,14 @@ private fun PagerCard(
) { pageIndex ->
when (pages[pageIndex]) {
RadarPage.Transceivers -> TransceiversPage(
transceivers = uiState.transmitters,
selectedUuid = uiState.selectedTransmitterUuid,
transceivers = uiState.transceivers.transmitters,
selectedUuid = uiState.transceivers.selectedUuid,
radioControl = uiState.radioControl,
onAction = onAction
)
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
dopplerFrequency = uiState.transceivers.selectedFrequency?.let { formatFrequency(it) },
onAction = onAction,
requestMicPermission = requestMicPermission
)
@@ -189,25 +191,26 @@ private fun PagerCard(
@Composable
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
val satellitePos = uiState.orbitalPos
val borderModifier = if (satellitePos?.aboveHorizon == true && satellitePos.eclipsed) {
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
val borderAlpha by infiniteTransition.animateFloat(
initialValue = 1.0f,
targetValue = 0.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "eclipsedBorderAlpha"
)
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
// Always call these composables unconditionally — conditional composable calls violate
// Compose's slot-table stability rules and can crash or produce incorrect state
val infiniteTransition = rememberInfiniteTransition(label = "eclipsedBorder")
val borderAlpha by infiniteTransition.animateFloat(
initialValue = 1.0f,
targetValue = 0.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "eclipsedBorderAlpha"
)
val borderModifier = if (shouldAnimateBorder) {
Modifier.border(
width = 0.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = borderAlpha),
shape = MaterialTheme.shapes.medium
)
} else {
Modifier
}
} else Modifier
ElevatedCard(modifier = modifier.then(borderModifier)) {
Box(contentAlignment = Alignment.Center) {
val position = uiState.orbitalPos
@@ -294,4 +297,3 @@ private fun RadarLabel(
}
}
}
@@ -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 passes = satelliteRepo.passes.value
val (catNum, aosTime) = satelliteRepo.selectedPass.value
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() ?: return@launch
_uiState.update { it.copy(currentPass = pass) }
val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum)
transponders = transmittersList.filter { it.downlinkLow != null }
_uiState.update { state ->
state.copy(radioControl = state.radioControl.copy(transponders = transponders))
}
if (transmittersList.isNotEmpty()) {
val firstUuid = transmittersList.first().uuid
_uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
}
if (!pass.isDeepSpace) {
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
_uiState.update { it.copy(satTrack = track) }
}
val pass = findCurrentPass() ?: return@launch
val allRadios = loadPassData(pass)
while (isActive) {
tickPass(pass, transmittersList)
delay(1000)
tickPass(pass, allRadios)
delay(1000.milliseconds)
}
}
}
private suspend fun tickPass(pass: OrbitalPass, transmittersList: List<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 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,59 +182,74 @@ internal fun SstvPage(
)
}
// Bottom control bar — dark, blends with the black canvas
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// Mode button — opens dialog
ElevatedCard(
modifier = Modifier.weight(1f).height(48.dp),
onClick = { showModeDialog.value = true },
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
// Doppler-corrected downlink frequency hint
if (dopplerFrequency != null) {
OutlinedText(
text = "RX: $dopplerFrequency Hz",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
fillColor = MaterialTheme.colorScheme.primary,
outlineColor = MaterialTheme.colorScheme.background,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Mode: ${sstv.selectedMode}",
fontSize = 14.sp,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.infiniteMarquee()
// Mode button — opens dialog
ElevatedCard(
modifier = Modifier.weight(1f).height(48.dp),
onClick = { showModeDialog.value = true },
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Mode: ${sstv.selectedMode}",
fontSize = 14.sp,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.infiniteMarquee()
)
}
}
}
// Reset button — clears decoder state and image
IconCard(
action = { onAction(RadarAction.SstvReset) },
resId = R.drawable.ic_delete
)
// Save button — always visible, enabled when there are pixels
IconCard(
action = { onAction(RadarAction.SstvSaveImage) },
resId = R.drawable.ic_save,
enabled = sstv.currentFrame?.imagePixels != null
)
// Record / Stop button
val playAction = {
when (sstv.status) {
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
SstvStatus.Saving -> {}
// Reset button — clears decoder state and image
IconCard(
action = { onAction(RadarAction.SstvReset) },
resId = R.drawable.ic_delete
)
// Save button — always visible, enabled when there are pixels
IconCard(
action = { onAction(RadarAction.SstvSaveImage) },
resId = R.drawable.ic_save,
enabled = sstv.currentFrame?.imagePixels != null && !sstv.isSaving
)
// Record / Stop button
val playAction = {
when (sstv.status) {
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
}
}
}
val playColors = CardDefaults.elevatedCardColors(
containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surface
)
val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play
ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(playIcon), contentDescription = null)
val playColors = CardDefaults.elevatedCardColors(
containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surface
)
val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play
ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(playIcon), contentDescription = null)
}
}
}
}
@@ -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
)