Skip to content

Commit c411737

Browse files
committed
feat: add night vision mode and privacy masking settings
- Implemented night vision mode selection in the NightVisionCard component. - Added PrivacyMaskingCard for configuring privacy masking zones with options for type, position, and size. - Introduced ConnectionQualityIndicator to display real-time connection quality metrics. - Enhanced StreamPreview to support tap-to-focus functionality and overlay display for streaming settings. - Updated StreamingSettings and CameraSettings types to include new properties for night vision and overlay configurations. - Created utility functions and constants for managing masking types and overlay positions.
1 parent db9c64d commit c411737

26 files changed

Lines changed: 2138 additions & 123 deletions

app/src/main/java/com/raulshma/lenscast/MainApplication.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,10 @@ class MainApplication : Application(), ImageLoaderFactory {
106106
streamingManager.setMdnsEnabled(enabled)
107107
}
108108
}
109+
appScope.launch {
110+
settingsDataStore.overlaySettings.collectLatest { overlay ->
111+
streamingManager.setOverlaySettings(overlay)
112+
}
113+
}
109114
}
110115
}

app/src/main/java/com/raulshma/lenscast/camera/CameraScreen.kt

Lines changed: 206 additions & 42 deletions
Large diffs are not rendered by default.

app/src/main/java/com/raulshma/lenscast/camera/CameraService.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import com.raulshma.lenscast.camera.model.CameraState
3434
import com.raulshma.lenscast.camera.model.FocusMode
3535
import com.raulshma.lenscast.camera.model.WhiteBalance
3636
import com.raulshma.lenscast.camera.model.HdrMode
37+
import com.raulshma.lenscast.camera.model.NightVisionMode
3738
import kotlinx.coroutines.Dispatchers
3839
import kotlinx.coroutines.flow.MutableStateFlow
3940
import kotlinx.coroutines.flow.StateFlow
@@ -138,6 +139,25 @@ class CameraService(private val context: Context) {
138139

139140
fun getCurrentCameraSelector(): CameraSelector = currentCameraSelector
140141

142+
fun tapToFocus(x: Float, y: Float) {
143+
val cam = camera ?: run {
144+
Log.w(TAG, "tapToFocus: camera not available")
145+
return
146+
}
147+
try {
148+
val factory = SurfaceOrientedMeteringPointFactory(1f, 1f)
149+
val point = factory.createPoint(x.coerceIn(0f, 1f), y.coerceIn(0f, 1f))
150+
cam.cameraControl.startFocusAndMetering(
151+
FocusMeteringAction.Builder(point)
152+
.setAutoCancelDuration(5, TimeUnit.SECONDS)
153+
.build()
154+
)
155+
Log.d(TAG, "tapToFocus: x=$x, y=$y")
156+
} catch (e: Exception) {
157+
Log.w(TAG, "tapToFocus failed", e)
158+
}
159+
}
160+
141161
fun setFrameListener(listener: ((ByteArray, Int, Int, Int) -> Unit)?) {
142162
frameListener = listener
143163
}
@@ -845,6 +865,24 @@ class CameraService(private val context: Context) {
845865
builder.setCaptureRequestOption(CaptureRequest.CONTROL_SCENE_MODE, it)
846866
}
847867

868+
when (settings.nightVisionMode) {
869+
NightVisionMode.ON -> {
870+
builder.setCaptureRequestOption(CaptureRequest.CONTROL_SCENE_MODE, CaptureRequest.CONTROL_SCENE_MODE_NIGHT)
871+
builder.setCaptureRequestOption(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON)
872+
builder.setCaptureRequestOption(CaptureRequest.CONTROL_AE_LOCK, false)
873+
val nightFpsRange = Range(10, settings.frameRate.coerceAtMost(15))
874+
builder.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, nightFpsRange)
875+
Log.d(TAG, "Night vision ON: scene=NIGHT, fps=$nightFpsRange")
876+
}
877+
NightVisionMode.AUTO -> {
878+
builder.setCaptureRequestOption(CaptureRequest.CONTROL_SCENE_MODE, CaptureRequest.CONTROL_SCENE_MODE_NIGHT_PORTRAIT)
879+
builder.setCaptureRequestOption(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH)
880+
Log.d(TAG, "Night vision AUTO: scene=NIGHT_PORTRAIT, auto flash")
881+
}
882+
NightVisionMode.OFF -> {
883+
}
884+
}
885+
848886
camera2Control.setCaptureRequestOptions(builder.build())
849887

850888
} catch (e: Exception) {

app/src/main/java/com/raulshma/lenscast/camera/CameraViewModel.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.raulshma.lenscast.camera.model.CameraSettings
2222
import com.raulshma.lenscast.camera.model.CameraState
2323
import com.raulshma.lenscast.camera.model.FocusMode
2424
import com.raulshma.lenscast.camera.model.HdrMode
25+
import com.raulshma.lenscast.camera.model.NightVisionMode
2526
import com.raulshma.lenscast.camera.model.Resolution
2627
import com.raulshma.lenscast.camera.model.StreamStatus
2728
import com.raulshma.lenscast.camera.model.WhiteBalance
@@ -110,6 +111,9 @@ class CameraViewModel(
110111
))
111112
val adaptiveBitrateState: StateFlow<AdaptiveBitrateController.AdaptiveState> = _adaptiveBitrateState.asStateFlow()
112113

114+
private val _connectionQualityStats = MutableStateFlow<com.raulshma.lenscast.core.NetworkQualityMonitor.NetworkStatsSnapshot?>(null)
115+
val connectionQualityStats: StateFlow<com.raulshma.lenscast.core.NetworkQualityMonitor.NetworkStatsSnapshot?> = _connectionQualityStats.asStateFlow()
116+
113117
private val _streamAudioEnabled = MutableStateFlow(true)
114118
private val _recordingAudioEnabled = MutableStateFlow(true)
115119

@@ -218,6 +222,18 @@ class CameraViewModel(
218222
_adaptiveBitrateState.value = state
219223
}
220224
}
225+
viewModelScope.launch {
226+
streamingManager.isStreaming.collect { isActive ->
227+
if (isActive) {
228+
while (true) {
229+
_connectionQualityStats.value = streamingManager.getNetworkStatsSnapshot()
230+
delay(1000)
231+
}
232+
} else {
233+
_connectionQualityStats.value = null
234+
}
235+
}
236+
}
221237

222238
// Run checkPermission AFTER collectors are set up
223239
checkPermission()
@@ -343,6 +359,10 @@ class CameraViewModel(
343359
updateSettings { it.copy(stabilization = enabled) }
344360
}
345361

362+
fun updateNightVisionMode(mode: String) {
363+
updateSettings { it.copy(nightVisionMode = NightVisionMode.valueOf(mode)) }
364+
}
365+
346366
fun togglePreview() {
347367
_showPreview.value = !_showPreview.value
348368
viewModelScope.launch {

app/src/main/java/com/raulshma/lenscast/camera/model/CameraSettings.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ enum class Resolution(val size: Size) {
2020

2121
enum class HdrMode { OFF, ON, AUTO }
2222

23+
enum class NightVisionMode { OFF, AUTO, ON }
24+
2325
data class CameraSettings(
2426
val exposureCompensation: Int = 0,
2527
val iso: Int? = null,
@@ -34,4 +36,5 @@ data class CameraSettings(
3436
val sceneMode: String? = null,
3537
val stabilization: Boolean = true,
3638
val hdrMode: HdrMode = HdrMode.OFF,
39+
val nightVisionMode: NightVisionMode = NightVisionMode.OFF,
3740
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.raulshma.lenscast.camera.model
2+
3+
import java.util.UUID
4+
5+
enum class OverlayPosition {
6+
TOP_LEFT,
7+
TOP_RIGHT,
8+
BOTTOM_LEFT,
9+
BOTTOM_RIGHT
10+
}
11+
12+
enum class MaskingType {
13+
BLACKOUT,
14+
PIXELATE,
15+
BLUR
16+
}
17+
18+
data class MaskingZone(
19+
val id: String = UUID.randomUUID().toString(),
20+
val label: String = "",
21+
val enabled: Boolean = true,
22+
val type: MaskingType = MaskingType.BLACKOUT,
23+
val x: Float = 0f,
24+
val y: Float = 0f,
25+
val width: Float = 0.2f,
26+
val height: Float = 0.2f,
27+
val pixelateSize: Int = 16,
28+
val blurRadius: Float = 10f,
29+
)
30+
31+
data class OverlaySettings(
32+
val enabled: Boolean = false,
33+
val showTimestamp: Boolean = true,
34+
val timestampFormat: String = "yyyy-MM-dd HH:mm:ss",
35+
val showBranding: Boolean = false,
36+
val brandingText: String = "LensCast",
37+
val showStatus: Boolean = false,
38+
val customText: String = "",
39+
val showCustomText: Boolean = false,
40+
val position: OverlayPosition = OverlayPosition.TOP_LEFT,
41+
val fontSize: Int = 28,
42+
val textColor: String = "#FFFFFF",
43+
val backgroundColor: String = "#80000000",
44+
val padding: Int = 8,
45+
val lineHeight: Int = 4,
46+
val maskingEnabled: Boolean = false,
47+
val maskingZones: List<MaskingZone> = emptyList(),
48+
)

app/src/main/java/com/raulshma/lenscast/core/NetworkQualityMonitor.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ class NetworkQualityMonitor {
227227
worstLatencyMs = getWorstClientLatencyMs(),
228228
qualityLevel = getNetworkQualityLevel(),
229229
clientDetails = clientDetails,
230+
avgFrameSizeBytes = if (clientDetails.isNotEmpty()) clientDetails.values.map { it.lastFrameSizeBytes }.average().toInt() else 0,
230231
)
231232
}
232233

@@ -258,6 +259,7 @@ class NetworkQualityMonitor {
258259
val worstLatencyMs: Long,
259260
val qualityLevel: NetworkQualityLevel,
260261
val clientDetails: Map<String, ClientStatsSnapshot>,
262+
val avgFrameSizeBytes: Int,
261263
)
262264

263265
enum class NetworkQualityLevel {

app/src/main/java/com/raulshma/lenscast/data/SettingsDataStore.kt

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.raulshma.lenscast.data
22

33
import android.content.Context
44
import android.util.Base64
5+
import android.util.Log
56
import androidx.datastore.core.DataStore
67
import androidx.datastore.preferences.core.Preferences
78
import androidx.datastore.preferences.core.doublePreferencesKey
@@ -14,11 +15,18 @@ import androidx.datastore.preferences.preferencesDataStore
1415
import com.raulshma.lenscast.camera.model.CameraSettings
1516
import com.raulshma.lenscast.camera.model.FocusMode
1617
import com.raulshma.lenscast.camera.model.HdrMode
18+
import com.raulshma.lenscast.camera.model.NightVisionMode
19+
import com.raulshma.lenscast.camera.model.MaskingType
20+
import com.raulshma.lenscast.camera.model.MaskingZone
21+
import com.raulshma.lenscast.camera.model.OverlayPosition
22+
import com.raulshma.lenscast.camera.model.OverlaySettings
1723
import com.raulshma.lenscast.camera.model.Resolution
1824
import com.raulshma.lenscast.camera.model.WhiteBalance
1925
import com.raulshma.lenscast.streaming.rtsp.RtspInputFormat
2026
import kotlinx.coroutines.flow.Flow
2127
import kotlinx.coroutines.flow.map
28+
import org.json.JSONArray
29+
import org.json.JSONObject
2230
import java.security.MessageDigest
2331
import java.security.SecureRandom
2432
import javax.crypto.SecretKeyFactory
@@ -139,6 +147,23 @@ class SettingsDataStore(private val context: Context) {
139147
val RTSP_INPUT_FORMAT = stringPreferencesKey("rtsp_input_format")
140148
val ADAPTIVE_BITRATE_ENABLED = stringPreferencesKey("adaptive_bitrate_enabled")
141149
val MDNS_ENABLED = stringPreferencesKey("mdns_enabled")
150+
val NIGHT_VISION_MODE = stringPreferencesKey("night_vision_mode")
151+
val OVERLAY_ENABLED = stringPreferencesKey("overlay_enabled")
152+
val OVERLAY_SHOW_TIMESTAMP = stringPreferencesKey("overlay_show_timestamp")
153+
val OVERLAY_TIMESTAMP_FORMAT = stringPreferencesKey("overlay_timestamp_format")
154+
val OVERLAY_SHOW_BRANDING = stringPreferencesKey("overlay_show_branding")
155+
val OVERLAY_BRANDING_TEXT = stringPreferencesKey("overlay_branding_text")
156+
val OVERLAY_SHOW_STATUS = stringPreferencesKey("overlay_show_status")
157+
val OVERLAY_SHOW_CUSTOM_TEXT = stringPreferencesKey("overlay_show_custom_text")
158+
val OVERLAY_CUSTOM_TEXT = stringPreferencesKey("overlay_custom_text")
159+
val OVERLAY_POSITION = stringPreferencesKey("overlay_position")
160+
val OVERLAY_FONT_SIZE = intPreferencesKey("overlay_font_size")
161+
val OVERLAY_TEXT_COLOR = stringPreferencesKey("overlay_text_color")
162+
val OVERLAY_BG_COLOR = stringPreferencesKey("overlay_bg_color")
163+
val OVERLAY_PADDING = intPreferencesKey("overlay_padding")
164+
val OVERLAY_LINE_HEIGHT = intPreferencesKey("overlay_line_height")
165+
val MASKING_ENABLED = stringPreferencesKey("masking_enabled")
166+
val MASKING_ZONES = stringPreferencesKey("masking_zones")
142167
}
143168

144169
val settings: Flow<CameraSettings> = context.dataStore.data.map { prefs ->
@@ -172,6 +197,11 @@ class SettingsDataStore(private val context: Context) {
172197
HdrMode.OFF
173198
},
174199
sceneMode = prefs[Keys.SCENE_MODE],
200+
nightVisionMode = try {
201+
NightVisionMode.valueOf(prefs[Keys.NIGHT_VISION_MODE] ?: NightVisionMode.OFF.name)
202+
} catch (_: Exception) {
203+
NightVisionMode.OFF
204+
},
175205
)
176206
}
177207

@@ -276,6 +306,7 @@ class SettingsDataStore(private val context: Context) {
276306
} else {
277307
prefs.remove(Keys.SCENE_MODE)
278308
}
309+
prefs[Keys.NIGHT_VISION_MODE] = settings.nightVisionMode.name
279310
}
280311
}
281312

@@ -385,4 +416,118 @@ class SettingsDataStore(private val context: Context) {
385416
prefs[Keys.MDNS_ENABLED] = if (enabled) "true" else "false"
386417
}
387418
}
419+
420+
val nightVisionMode: Flow<NightVisionMode> = context.dataStore.data.map { prefs ->
421+
val raw = prefs[Keys.NIGHT_VISION_MODE] ?: NightVisionMode.OFF.name
422+
try {
423+
NightVisionMode.valueOf(raw)
424+
} catch (_: Exception) {
425+
NightVisionMode.OFF
426+
}
427+
}
428+
429+
suspend fun saveNightVisionMode(mode: NightVisionMode) {
430+
context.dataStore.edit { prefs ->
431+
prefs[Keys.NIGHT_VISION_MODE] = mode.name
432+
}
433+
}
434+
435+
val overlaySettings: Flow<OverlaySettings> = context.dataStore.data.map { prefs ->
436+
OverlaySettings(
437+
enabled = prefs[Keys.OVERLAY_ENABLED] == "true",
438+
showTimestamp = prefs[Keys.OVERLAY_SHOW_TIMESTAMP] != "false",
439+
timestampFormat = prefs[Keys.OVERLAY_TIMESTAMP_FORMAT] ?: "yyyy-MM-dd HH:mm:ss",
440+
showBranding = prefs[Keys.OVERLAY_SHOW_BRANDING] == "true",
441+
brandingText = prefs[Keys.OVERLAY_BRANDING_TEXT] ?: "LensCast",
442+
showStatus = prefs[Keys.OVERLAY_SHOW_STATUS] == "true",
443+
showCustomText = prefs[Keys.OVERLAY_SHOW_CUSTOM_TEXT] == "true",
444+
customText = prefs[Keys.OVERLAY_CUSTOM_TEXT] ?: "",
445+
position = try {
446+
OverlayPosition.valueOf(prefs[Keys.OVERLAY_POSITION] ?: OverlayPosition.TOP_LEFT.name)
447+
} catch (_: Exception) {
448+
OverlayPosition.TOP_LEFT
449+
},
450+
fontSize = prefs[Keys.OVERLAY_FONT_SIZE] ?: 28,
451+
textColor = prefs[Keys.OVERLAY_TEXT_COLOR] ?: "#FFFFFF",
452+
backgroundColor = prefs[Keys.OVERLAY_BG_COLOR] ?: "#80000000",
453+
padding = prefs[Keys.OVERLAY_PADDING] ?: 8,
454+
lineHeight = prefs[Keys.OVERLAY_LINE_HEIGHT] ?: 4,
455+
maskingEnabled = prefs[Keys.MASKING_ENABLED] == "true",
456+
maskingZones = parseMaskingZones(prefs[Keys.MASKING_ZONES]),
457+
)
458+
}
459+
460+
suspend fun saveOverlaySettings(settings: OverlaySettings) {
461+
context.dataStore.edit { prefs ->
462+
prefs[Keys.OVERLAY_ENABLED] = if (settings.enabled) "true" else "false"
463+
prefs[Keys.OVERLAY_SHOW_TIMESTAMP] = if (settings.showTimestamp) "true" else "false"
464+
prefs[Keys.OVERLAY_TIMESTAMP_FORMAT] = settings.timestampFormat
465+
prefs[Keys.OVERLAY_SHOW_BRANDING] = if (settings.showBranding) "true" else "false"
466+
prefs[Keys.OVERLAY_BRANDING_TEXT] = settings.brandingText
467+
prefs[Keys.OVERLAY_SHOW_STATUS] = if (settings.showStatus) "true" else "false"
468+
prefs[Keys.OVERLAY_SHOW_CUSTOM_TEXT] = if (settings.showCustomText) "true" else "false"
469+
prefs[Keys.OVERLAY_CUSTOM_TEXT] = settings.customText
470+
prefs[Keys.OVERLAY_POSITION] = settings.position.name
471+
prefs[Keys.OVERLAY_FONT_SIZE] = settings.fontSize
472+
prefs[Keys.OVERLAY_TEXT_COLOR] = settings.textColor
473+
prefs[Keys.OVERLAY_BG_COLOR] = settings.backgroundColor
474+
prefs[Keys.OVERLAY_PADDING] = settings.padding
475+
prefs[Keys.OVERLAY_LINE_HEIGHT] = settings.lineHeight
476+
prefs[Keys.MASKING_ENABLED] = if (settings.maskingEnabled) "true" else "false"
477+
prefs[Keys.MASKING_ZONES] = serializeMaskingZones(settings.maskingZones)
478+
}
479+
}
480+
481+
private fun parseMaskingZones(jsonString: String?): List<MaskingZone> {
482+
if (jsonString.isNullOrEmpty()) return emptyList()
483+
return try {
484+
val array = JSONArray(jsonString)
485+
List(array.length()) { i ->
486+
val obj = array.getJSONObject(i)
487+
MaskingZone(
488+
id = obj.optString("id", java.util.UUID.randomUUID().toString()),
489+
label = obj.optString("label", ""),
490+
enabled = obj.optBoolean("enabled", true),
491+
type = try {
492+
MaskingType.valueOf(obj.optString("type", MaskingType.BLACKOUT.name))
493+
} catch (_: Exception) {
494+
MaskingType.BLACKOUT
495+
},
496+
x = obj.optDouble("x", 0.0).toFloat(),
497+
y = obj.optDouble("y", 0.0).toFloat(),
498+
width = obj.optDouble("width", 0.2).toFloat(),
499+
height = obj.optDouble("height", 0.2).toFloat(),
500+
pixelateSize = obj.optInt("pixelateSize", 16),
501+
blurRadius = obj.optDouble("blurRadius", 10.0).toFloat(),
502+
)
503+
}
504+
} catch (e: Exception) {
505+
Log.e("SettingsDataStore", "Failed to parse masking zones", e)
506+
emptyList()
507+
}
508+
}
509+
510+
private fun serializeMaskingZones(zones: List<MaskingZone>): String {
511+
return try {
512+
val array = JSONArray()
513+
for (zone in zones) {
514+
val obj = JSONObject()
515+
obj.put("id", zone.id)
516+
obj.put("label", zone.label)
517+
obj.put("enabled", zone.enabled)
518+
obj.put("type", zone.type.name)
519+
obj.put("x", zone.x.toDouble())
520+
obj.put("y", zone.y.toDouble())
521+
obj.put("width", zone.width.toDouble())
522+
obj.put("height", zone.height.toDouble())
523+
obj.put("pixelateSize", zone.pixelateSize)
524+
obj.put("blurRadius", zone.blurRadius.toDouble())
525+
array.put(obj)
526+
}
527+
array.toString()
528+
} catch (e: Exception) {
529+
Log.e("SettingsDataStore", "Failed to serialize masking zones", e)
530+
"[]"
531+
}
532+
}
388533
}

0 commit comments

Comments
 (0)