Added SSTV image decoding functionality to RadarScreen

This commit is contained in:
Arty Bishop committed 2026-06-02 20:06:22 +01:00
1 parent f7f5a73c4c
commit 79809aaec0
24 files changed
+1991 -470

No files matched your search

@@ -17,6 +17,10 @@
*/
package com.rtbishop.look4sat.feature.radar
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
@@ -39,6 +43,7 @@ import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -49,6 +54,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
@@ -76,14 +82,27 @@ fun RadarDestination(navigateUp: () -> Unit) {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadarScreen(uiState, viewModel::onAction, navigateUp)
// Sync actual permission state on every recomposition so it survives screen re-entry
val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
LaunchedEffect(hasPermission) {
viewModel.onAction(RadarAction.SstvPermissionResult(hasPermission))
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> viewModel.onAction(RadarAction.SstvPermissionResult(granted)) }
RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
}
@Composable
private fun RadarScreen(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit
navigateUp: () -> Unit,
requestMicPermission: () -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = {
@@ -113,11 +132,11 @@ private fun RadarScreen(
}
if (isVertical) {
RadarCard(uiState, Modifier.weight(1f))
PagerCard(uiState, onAction, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, Modifier.weight(1f))
PagerCard(uiState, onAction, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
}
}
}
@@ -127,6 +146,7 @@ private fun RadarScreen(
private fun PagerCard(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
) {
val pages = RadarPage.entries
@@ -155,38 +175,17 @@ private fun PagerCard(
radioControl = uiState.radioControl,
onAction = onAction
)
RadarPage.Sstv -> SstvPlaceholderPage()
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
onAction = onAction,
requestMicPermission = requestMicPermission
)
}
}
}
}
}
@Composable
private fun SstvPlaceholderPage() {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(text = "🚧", fontSize = 48.sp)
Text(
text = "SSTV Decoder",
fontSize = 20.sp,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Coming soon",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
val satellitePos = uiState.orbitalPos
@@ -21,6 +21,7 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
data class RadioPanelState(
val label: String = "",
@@ -57,7 +58,18 @@ data class RadarState(
val transmitters: List<SatRadio> = emptyList(),
val selectedTransmitterUuid: String? = null,
val selectedFrequency: Long? = null,
val radioControl: RadioControlSubState = RadioControlSubState()
val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState()
)
enum class SstvStatus { Idle, Recording, Saving }
data class SstvSubState(
val status: SstvStatus = SstvStatus.Idle,
val hasPermission: Boolean = false,
val selectedMode: String = "Auto",
val supportedModes: List<String> = emptyList(),
val currentFrame: SstvFrame? = null
)
sealed interface RadarAction {
@@ -72,4 +84,12 @@ sealed interface RadarAction {
data object ToggleTracking : RadarAction
data object ConnectRadios : RadarAction
data object DisconnectRadios : RadarAction
// SSTV actions
data object SstvStartRecording : RadarAction
data object SstvStopRecording : RadarAction
data object SstvSaveImage : RadarAction
data object SstvReset : RadarAction
data class SstvSelectMode(val modeName: String) : RadarAction
data class SstvPermissionResult(val granted: Boolean) : RadarAction
}
@@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.StampedPathEffectStyle
import androidx.compose.ui.graphics.SweepGradientShader
import androidx.compose.ui.graphics.drawscope.DrawScope
@@ -220,7 +221,7 @@ private fun DrawScope.drawBodyIcon(
translate(pos.x - half, pos.y - half)
}) {
with(painter) {
draw(Size(iconSize, iconSize), colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(color))
draw(Size(iconSize, iconSize), colorFilter = ColorFilter.tint(color))
}
}
}
@@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
@@ -31,10 +32,15 @@ import com.rtbishop.look4sat.core.domain.repository.IReporter
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
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 kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -51,91 +57,100 @@ class RadarViewModel(
private val settingsRepo: ISettingsRepo,
private val sensorsRepo: ISensorsRepo,
private val addToCalendar: IAddToCalendar,
private val trackingService: IRadioTrackingService
private val trackingService: IRadioTrackingService,
private val audioCapture: IAudioCapture,
private val saveImage: ISaveImage,
private val showToast: IShowToast
) : ViewModel() {
private val stationPos = settingsRepo.stationPosition.value
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
private var transponders: List<SatRadio> = emptyList()
private var sstvDecoder: SstvDecoder? = null
private var sstvRecordingJob: Job? = null
private val _uiState = MutableStateFlow(
RadarState(
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
orientationValues = sensorsRepo.orientation.value,
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
)
)
val uiState: StateFlow<RadarState> = _uiState
init {
// Compass sensor collection
if (settingsRepo.otherSettings.value.stateOfSensors) {
viewModelScope.launch {
sensorsRepo.enableSensor()
sensorsRepo.orientation.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second
_uiState.update { it.copy(orientationValues = orientationValues) }
}
collectCompassSensor()
collectSettingsChanges()
collectPassAndStartTickLoop()
collectRadioTrackingState()
}
private fun collectCompassSensor() {
if (!settingsRepo.otherSettings.value.stateOfSensors) return
viewModelScope.launch {
sensorsRepo.enableSensor()
sensorsRepo.orientation.collect { data ->
val orientationValues = (data.first + magDeclination) to data.second
_uiState.update { it.copy(orientationValues = orientationValues) }
}
}
// React to UTC setting changes
}
private fun collectSettingsChanges() {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
}
}
// Resolve which pass we're tracking and start the tick loop
}
private fun collectPassAndStartTickLoop() {
viewModelScope.launch {
val passes = satelliteRepo.passes.value
val (catNum, aosTime) = satelliteRepo.selectedPass.value
val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
satPass?.let { pass ->
_uiState.update { it.copy(currentPass = pass) }
val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum)
// Load transponders for radio control (ones that have downlink)
transponders = transmittersList.filter { it.downlinkLow != null }
_uiState.update { state ->
state.copy(radioControl = state.radioControl.copy(transponders = transponders))
}
// Auto-expand the first item so users discover the expandable UI
if (transmittersList.isNotEmpty()) {
val firstUuid = transmittersList.first().uuid
_uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
transponders.find { it.uuid == firstUuid }?.let {
trackingService.setTransponder(it)
}
}
// Compute track once (it doesn't change for a given pass)
if (!pass.isDeepSpace) {
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
_uiState.update { it.copy(satTrack = track) }
}
// Tick loop — position, timer, and radio updates every second
while (isActive) {
val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
val moonPos = CelestialComputer.getMoonPosition(stationPos, 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
)
}
processRadios(transmittersList, pass.orbitalObject, timeNow)
sendPassData(pos)
delay(1000)
}
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) {
tickPass(pass, transmittersList)
delay(1000)
}
}
// Observe radio tracking service state
}
private suspend fun tickPass(pass: OrbitalPass, transmittersList: 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)
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
)
}
processRadios(transmittersList, pass.orbitalObject, timeNow)
sendPassData(pos)
}
private fun collectRadioTrackingState() {
viewModelScope.launch {
trackingService.state.collect { svc ->
_uiState.update { state ->
@@ -210,6 +225,33 @@ class RadarViewModel(
}
RadarAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
RadarAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
// SSTV actions
is RadarAction.SstvPermissionResult -> {
_uiState.update { it.copy(sstv = it.sstv.copy(hasPermission = action.granted)) }
if (action.granted) initSstvDecoder()
}
RadarAction.SstvStartRecording -> startSstvRecording()
RadarAction.SstvStopRecording -> stopSstvRecording()
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)) }
viewModelScope.launch {
saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
showToast("Image saved")
}
}
is RadarAction.SstvSelectMode -> {
sstvDecoder?.lockMode(action.modeName)
_uiState.update { it.copy(sstv = it.sstv.copy(selectedMode = action.modeName)) }
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
}
RadarAction.SstvReset -> {
sstvDecoder?.clearPixels() // Keep decoder alive, just reset pixels
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
}
}
}
@@ -281,6 +323,36 @@ class RadarViewModel(
}
}
private fun initSstvDecoder() {
if (sstvDecoder == null) {
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
sstvDecoder = decoder
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
viewModelScope.launch {
decoder.frames.collect { frame ->
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = frame)) }
}
}
}
}
private fun startSstvRecording() {
if (sstvRecordingJob?.isActive == true) return
initSstvDecoder()
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Recording)) }
sstvRecordingJob = viewModelScope.launch {
audioCapture.audioFlow().collect { buffer ->
sstvDecoder?.feedSamples(buffer)
}
}
}
private fun stopSstvRecording() {
sstvRecordingJob?.cancel()
sstvRecordingJob = null
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
}
companion object {
val CTCSS_TONES = listOf(
@@ -307,7 +379,10 @@ class RadarViewModel(
settingsRepo = container.settingsRepo,
sensorsRepo = container.provideSensorsRepo(),
addToCalendar = container.provideAddToCalendar(),
trackingService = container.radioTrackingService
trackingService = container.radioTrackingService,
audioCapture = container.provideAudioCapture(),
saveImage = container.provideSaveImage(),
showToast = container.provideShowToast()
)
}
}
@@ -1,369 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.radar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.presentation.CardButton
import java.util.Locale
@Composable
fun RadioControlPage(
radioState: RadioControlSubState,
onAction: (RadarAction) -> Unit,
modifier: Modifier = Modifier
) {
val selectedTransponder = radioState.transponders.find {
it.uuid == radioState.selectedTransponderUuid
}
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 6.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Transponder selector (dropdown)
TransponderDropdown(
transponders = radioState.transponders,
selectedTransponder = selectedTransponder,
onAction = onAction
)
// Active transponder status (TX/RX lines)
if (selectedTransponder != null) {
ActiveTransponderStatus(
transponder = selectedTransponder,
txPanel = radioState.txPanel,
rxPanel = radioState.rxPanel
)
}
// Frequency tuner + CTCSS
if (radioState.txBaseFrequencyHz != null) {
FrequencyTunerCard(
txBaseFrequencyHz = radioState.txBaseFrequencyHz,
selectedTransponder = selectedTransponder,
ctcssTone = radioState.ctcssTone,
showCtcss = selectedTransponder?.uplinkMode?.uppercase() == "FM",
onAction = onAction
)
}
Spacer(modifier = Modifier.weight(1f))
// Error message
radioState.errorMessage?.let { msg ->
Text(
text = msg,
color = MaterialTheme.colorScheme.error,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
// Control buttons
ControlButtons(
isConnected = radioState.txPanel.isConnected || radioState.rxPanel.isConnected,
isTracking = radioState.isTracking,
onAction = onAction
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TransponderDropdown(
transponders: List<SatRadio>,
selectedTransponder: SatRadio?,
onAction: (RadarAction) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = selectedTransponder?.info ?: "No transponders available",
onValueChange = {},
readOnly = true,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
transponders.forEach { radio ->
val invLabel = if (radio.isInverted) " INV" else ""
val modeLabel = "${radio.uplinkMode ?: "--"}/${radio.downlinkMode ?: "--"}$invLabel"
DropdownMenuItem(
text = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = radio.info,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = modeLabel,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = {
onAction(RadarAction.SelectTransponder(radio.uuid))
expanded = false
}
)
}
}
}
}
@Composable
private fun ActiveTransponderStatus(
transponder: SatRadio,
txPanel: RadioPanelState,
rxPanel: RadioPanelState
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// TX line (uplink — on top)
FrequencyStatusRow(
label = "TX",
frequency = txPanel.frequencyHz ?: transponder.uplinkLow,
isConnected = txPanel.isConnected,
mode = txPanel.mode
)
// RX line (downlink — below)
FrequencyStatusRow(
label = "RX",
frequency = rxPanel.frequencyHz ?: transponder.downlinkLow,
isConnected = rxPanel.isConnected,
mode = rxPanel.mode
)
}
}
}
@Composable
private fun FrequencyStatusRow(
label: String,
frequency: Long?,
isConnected: Boolean,
mode: String?
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "$label:",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(28.dp)
)
Text(
text = frequency?.let { RadarViewModel.formatFrequency(it) } ?: "—",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
if (mode != null) {
Text(
text = mode,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 6.dp)
)
}
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (isConnected) Color(0xFF4CAF50) else Color(0xFFE57373))
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FrequencyTunerCard(
txBaseFrequencyHz: Long,
selectedTransponder: SatRadio?,
ctcssTone: Double?,
showCtcss: Boolean,
onAction: (RadarAction) -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
) {
// TX Base Frequency label + value on one line
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "TX Base: ",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${RadarViewModel.formatFrequency(txBaseFrequencyHz)} MHz",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
// Show band range hint if it's a wide transponder
if (selectedTransponder != null) {
val upLow = selectedTransponder.uplinkLow
val upHigh = selectedTransponder.uplinkHigh
if (upLow != null && upHigh != null && upLow != upHigh) {
Text(
text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
// Adjustment buttons
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
CardButton(
onClick = { onAction(RadarAction.AdjustTxFrequency(delta)) },
text = label,
modifier = Modifier.weight(1f)
)
}
}
// CTCSS inline (only for FM)
if (showCtcss) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "CTCSS",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
FilterChip(
selected = ctcssTone == null,
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
label = { Text("Off", fontSize = 12.sp) }
)
RadarViewModel.CTCSS_TONES.forEach { tone ->
FilterChip(
selected = ctcssTone == tone,
onClick = { onAction(RadarAction.SetCtcssTone(tone)) },
label = { Text(String.format(Locale.ENGLISH, "%.1f", tone), fontSize = 12.sp) }
)
}
}
}
}
}
}
@Composable
private fun ControlButtons(
isConnected: Boolean,
isTracking: Boolean,
onAction: (RadarAction) -> Unit
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (!isConnected) {
CardButton(
onClick = { onAction(RadarAction.ConnectRadios) },
text = "Connect",
modifier = Modifier.weight(1f)
)
} else {
CardButton(
onClick = { onAction(RadarAction.DisconnectRadios) },
text = "Disconnect",
modifier = Modifier.weight(1f)
)
}
CardButton(
onClick = { onAction(RadarAction.ToggleTracking) },
text = if (isTracking) "Stop" else "Track",
modifier = Modifier.weight(1f)
)
}
}
private val FREQ_ADJUSTMENTS =
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
@@ -0,0 +1,240 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.radar
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
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.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.R
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@Composable
internal fun SstvPage(
sstv: SstvSubState,
onAction: (RadarAction) -> Unit,
requestMicPermission: () -> Unit
) {
if (!sstv.hasPermission) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = "🎙️", fontSize = 48.sp)
Text(
text = "Microphone access needed",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface
)
Button(onClick = requestMicPermission) {
Text("Grant permission")
}
}
}
return
}
// Mode selection dialog
val showModeDialog = remember { mutableStateOf(false) }
if (showModeDialog.value) {
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
Dialog(onDismissRequest = { showModeDialog.value = false }) {
ElevatedCard {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(vertical = 16.dp)
) {
Text(
text = "SSTV Mode",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 12.dp)
)
LazyColumn(
modifier = Modifier
.fillMaxHeight(0.5f)
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
items(allModes) { mode ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.clickable {
onAction(RadarAction.SstvSelectMode(mode))
showModeDialog.value = false
}
) {
Text(
text = mode,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
)
RadioButton(
selected = mode == sstv.selectedMode,
onClick = null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
}
}
}
}
}
}
// Main SSTV view — image fills entire area, controls overlay at bottom
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
// Decoded image or placeholder — centered in available space
val frame = sstv.currentFrame
val pixels = frame?.imagePixels
if (pixels != null && frame.imageWidth > 0 && frame.imageHeight > 0) {
val bitmap = remember(pixels, frame.imageWidth, frame.imageHeight) {
Bitmap.createBitmap(
pixels,
frame.imageWidth,
frame.imageHeight,
Bitmap.Config.ARGB_8888
).asImageBitmap()
}
Image(
bitmap = bitmap,
contentDescription = "Decoded SSTV image",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
)
} else {
Text(
text = if (sstv.status == SstvStatus.Recording) "Listening…" else "No signal",
color = MaterialTheme.colorScheme.onSurface,
fontSize = 16.sp,
modifier = Modifier.align(Alignment.Center)
)
}
// Bottom control bar — dark, blends with the black canvas
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
) {
// 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 -> {}
}
}
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)
}
}
}
}
}