Skip to content

Commit 0ce7fba

Browse files
devchankimclaude
andcommitted
Add baby monitor improvements: reliability, cry/motion detection, UX
Android: BalioFVFX#1 Infinite reconnection with 30s-capped exponential back-off BalioFVFX#2 Keepalive ping every 15s to detect silent WebSocket drops BalioFVFX#4 Battery-optimisation exemption request (REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) BalioFVFX#5 CryDetector: AudioRecord-based cry detection → sends cry_detected signal BalioFVFX#6 H.264 SDP preference (SDP munging) + night-mode 15fps setting #8 MotionDetector: VideoSink frame-diff → ntfy push notification (NtfyNotifier) #9 TURN server support via Settings (URL / username / credential) #10 Adaptive max-bitrate via RtpSender.setParameters() + RTCStats polling #15 Dynamic room name from Settings (default: baby) New settings UI: room name, ntfy topic, TURN fields, night mode / cry detect switches Server (VideoServer): BalioFVFX#2 setConnectionLostTimeout(30) on signaling WebSocket server BalioFVFX#3 GET /health endpoint on static file server → {"status":"ok","uptime":N} BalioFVFX#5 cry_detected relayed from camera to all viewers in room #9 TURN_SERVERS env var → injected into "joined" response as iceServers[] #17 AccessLogger: appends every join/leave/request to access.log Web viewer: BalioFVFX#3 Server-offline banner polls /health every 5s when disconnected BalioFVFX#5 cry_detected → glass toast (bottom-right, auto-dismiss 30s, X to close) BalioFVFX#7 navigator.wakeLock to prevent screen sleep while streaming #9 TURN server fields in Advanced section; merges server + user TURN config #11 Latency + quality badge (RTT ms · good/fair/poor) from RTCPeerConnection stats #15 Room name input (saved to localStorage) #16 Auto wss:// when page loaded from https:// Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 297ab00 commit 0ce7fba

13 files changed

Lines changed: 1870 additions & 974 deletions

File tree

Andorid/app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
1212
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
1313
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
14+
<!-- #4 Battery optimisation exemption -->
15+
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
1416

1517
<application
1618
android:allowBackup="false"

Andorid/app/src/main/java/com/ipcamera/SettingsFragment.kt

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ class SettingsFragment : Fragment() {
1818
savedInstanceState: Bundle?
1919
): View {
2020
binding = SettingsFragmentBinding.inflate(inflater, container, false)
21-
2221
return binding.root
2322
}
2423

@@ -32,61 +31,84 @@ class SettingsFragment : Fragment() {
3231

3332
val prefs = SettingsPreferences(requireContext().applicationContext)
3433

35-
prefs.getIpAddress()?.let { ipAddress ->
36-
binding.editTextIp.setText(ipAddress)
37-
}
38-
39-
prefs.getSignalingToken()?.let { token ->
40-
binding.editTextToken.setText(token)
41-
}
34+
// ── Connection ────────────────────────────────────────────────────────
35+
prefs.getIpAddress()?.let { binding.editTextIp.setText(it) }
36+
prefs.getSignalingToken()?.let { binding.editTextToken.setText(it) }
37+
binding.editTextRoom.setText(prefs.getRoomName())
4238

43-
// Defaults: rear camera + standard quality + STUN off
39+
// ── Camera ────────────────────────────────────────────────────────────
4440
when (prefs.getCameraFacing()) {
4541
"front" -> binding.toggleCameraFacing.check(R.id.btn_camera_front)
46-
else -> binding.toggleCameraFacing.check(R.id.btn_camera_back)
42+
else -> binding.toggleCameraFacing.check(R.id.btn_camera_back)
4743
}
48-
4944
when (prefs.getQualityPreset()) {
50-
"low" -> binding.toggleQuality.check(R.id.btn_quality_low)
45+
"low" -> binding.toggleQuality.check(R.id.btn_quality_low)
5146
"high" -> binding.toggleQuality.check(R.id.btn_quality_high)
52-
else -> binding.toggleQuality.check(R.id.btn_quality_medium)
47+
else -> binding.toggleQuality.check(R.id.btn_quality_medium)
5348
}
5449

55-
binding.switchStun.isChecked = prefs.isStunFallbackEnabled()
50+
// ── Switches ──────────────────────────────────────────────────────────
51+
binding.switchStun.isChecked = prefs.isStunFallbackEnabled()
52+
binding.switchNightMode.isChecked = prefs.isNightModeEnabled()
53+
binding.switchCryDetect.isChecked = prefs.isCryDetectEnabled()
5654

57-
binding.editTextIp.addTextChangedListener {
58-
if (binding.textInputIp.error != null) {
59-
binding.textInputIp.error = null
60-
}
55+
// ── ntfy ──────────────────────────────────────────────────────────────
56+
binding.editTextNtfyTopic.setText(prefs.getNtfyTopic())
57+
val savedBaseUrl = prefs.getNtfyBaseUrl()
58+
if (savedBaseUrl != "https://ntfy.sh") {
59+
binding.editTextNtfyBaseUrl.setText(savedBaseUrl)
6160
}
6261

63-
binding.btnSave.setOnClickListener {
64-
val input = binding.editTextIp.text?.toString() ?: ""
62+
// ── TURN ──────────────────────────────────────────────────────────────
63+
binding.editTextTurnUrl.setText(prefs.getTurnUrl())
64+
binding.editTextTurnUser.setText(prefs.getTurnUsername())
65+
binding.editTextTurnCred.setText(prefs.getTurnCredential())
6566

66-
val portSeparatorCount = input.count { it == ':' }
67+
// Clear validation errors on typing
68+
binding.editTextIp.addTextChangedListener {
69+
if (binding.textInputIp.error != null) binding.textInputIp.error = null
70+
}
6771

68-
if (portSeparatorCount != 1 || input.length <= 10) {
72+
// ── Save ──────────────────────────────────────────────────────────────
73+
binding.btnSave.setOnClickListener {
74+
val ipInput = binding.editTextIp.text?.toString() ?: ""
75+
val colonCount = ipInput.count { it == ':' }
76+
if (colonCount != 1 || ipInput.length <= 10) {
6977
binding.textInputIp.error = "Invalid IP format provided"
7078
return@setOnClickListener
7179
}
7280

73-
val token = binding.editTextToken.text?.toString() ?: ""
81+
prefs.saveIpAddress(ipInput)
82+
prefs.saveSignalingToken(binding.editTextToken.text?.toString() ?: "")
83+
prefs.setRoomName(binding.editTextRoom.text?.toString() ?: "baby")
7484

75-
prefs.saveIpAddress(input)
76-
prefs.saveSignalingToken(token)
7785
prefs.setCameraFacing(
7886
if (binding.toggleCameraFacing.checkedButtonId == R.id.btn_camera_front) "front" else "back"
7987
)
8088
prefs.setQualityPreset(
8189
when (binding.toggleQuality.checkedButtonId) {
82-
R.id.btn_quality_low -> "low"
90+
R.id.btn_quality_low -> "low"
8391
R.id.btn_quality_high -> "high"
84-
else -> "medium"
92+
else -> "medium"
8593
}
8694
)
95+
8796
prefs.setStunFallbackEnabled(binding.switchStun.isChecked)
97+
prefs.setNightModeEnabled(binding.switchNightMode.isChecked)
98+
prefs.setCryDetectEnabled(binding.switchCryDetect.isChecked)
99+
100+
// ntfy
101+
prefs.setNtfyTopic(binding.editTextNtfyTopic.text?.toString() ?: "")
102+
val ntfyBase = binding.editTextNtfyBaseUrl.text?.toString()?.trim() ?: ""
103+
prefs.setNtfyBaseUrl(ntfyBase)
104+
105+
// TURN
106+
prefs.setTurnUrl(binding.editTextTurnUrl.text?.toString() ?: "")
107+
prefs.setTurnUsername(binding.editTextTurnUser.text?.toString() ?: "")
108+
prefs.setTurnCredential(binding.editTextTurnCred.text?.toString() ?: "")
88109

110+
@Suppress("DEPRECATION")
89111
activity?.onBackPressed()
90112
}
91113
}
92-
}
114+
}

Andorid/app/src/main/java/com/ipcamera/SettingsPreferences.kt

Lines changed: 56 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,64 +5,72 @@ import android.content.Context
55
class SettingsPreferences(context: Context) {
66

77
companion object {
8-
private const val IP_KEY = "ip"
9-
private const val TOKEN_KEY = "signaling_token"
10-
private const val CAMERA_FACING_KEY = "camera_facing" // "back" | "front"
11-
private const val QUALITY_KEY = "quality" // "low" | "medium" | "high"
12-
private const val STUN_FALLBACK_KEY = "stun_fallback" // boolean
8+
private const val IP_KEY = "ip"
9+
private const val TOKEN_KEY = "signaling_token"
10+
private const val CAMERA_FACING_KEY = "camera_facing" // "back" | "front"
11+
private const val QUALITY_KEY = "quality" // "low" | "medium" | "high"
12+
private const val STUN_FALLBACK_KEY = "stun_fallback" // boolean
13+
// #15 Dynamic room name
14+
private const val ROOM_NAME_KEY = "room_name" // default "baby"
15+
// #6 Night mode
16+
private const val NIGHT_MODE_KEY = "night_mode" // boolean
17+
// #5 Cry detection
18+
private const val CRY_DETECT_KEY = "cry_detect" // boolean
19+
// #8 Motion detection / ntfy
20+
private const val NTFY_TOPIC_KEY = "ntfy_topic" // e.g. "babycam-xk83js"
21+
private const val NTFY_BASE_URL_KEY = "ntfy_base_url" // default "https://ntfy.sh"
22+
// #9 TURN server
23+
private const val TURN_URL_KEY = "turn_url" // e.g. "turn:my.turn.com:3478"
24+
private const val TURN_USERNAME_KEY = "turn_username"
25+
private const val TURN_CREDENTIAL_KEY = "turn_credential"
1326
}
1427

15-
private val sharedPreferences =
16-
context.getSharedPreferences("settings_prefs", Context.MODE_PRIVATE)
28+
private val prefs = context.getSharedPreferences("settings_prefs", Context.MODE_PRIVATE)
1729

30+
// ── Signaling ─────────────────────────────────────────────────────────────
31+
fun saveIpAddress(ip: String) = prefs.edit().putString(IP_KEY, ip).apply()
32+
fun getIpAddress(): String? = prefs.getString(IP_KEY, "192.168.0.101:8081")
1833

19-
fun saveIpAddress(ip: String) {
20-
sharedPreferences.edit()
21-
.putString(IP_KEY, ip)
22-
.apply()
23-
}
34+
fun saveSignalingToken(token: String) = prefs.edit().putString(TOKEN_KEY, token).apply()
35+
fun getSignalingToken(): String? = prefs.getString(TOKEN_KEY, "")
2436

25-
fun getIpAddress() : String? {
26-
return sharedPreferences.getString(IP_KEY, "192.168.0.101:8081")
27-
}
37+
// ── Room name (#15) ───────────────────────────────────────────────────────
38+
fun setRoomName(name: String) = prefs.edit().putString(ROOM_NAME_KEY, name.ifBlank { "baby" }).apply()
39+
fun getRoomName(): String = prefs.getString(ROOM_NAME_KEY, "baby")?.ifBlank { "baby" } ?: "baby"
2840

29-
fun saveSignalingToken(token: String) {
30-
sharedPreferences.edit()
31-
.putString(TOKEN_KEY, token)
32-
.apply()
33-
}
41+
// ── Camera ───────────────────────────────────────────────────────────────
42+
fun setCameraFacing(facing: String) = prefs.edit().putString(CAMERA_FACING_KEY, facing).apply()
43+
fun getCameraFacing(): String = prefs.getString(CAMERA_FACING_KEY, "back") ?: "back"
3444

35-
fun getSignalingToken(): String? {
36-
return sharedPreferences.getString(TOKEN_KEY, "")
37-
}
45+
fun setQualityPreset(preset: String) = prefs.edit().putString(QUALITY_KEY, preset).apply()
46+
fun getQualityPreset(): String = prefs.getString(QUALITY_KEY, "medium") ?: "medium"
3847

39-
fun setCameraFacing(facing: String) {
40-
sharedPreferences.edit()
41-
.putString(CAMERA_FACING_KEY, facing)
42-
.apply()
43-
}
48+
// ── STUN fallback ─────────────────────────────────────────────────────────
49+
fun setStunFallbackEnabled(enabled: Boolean) = prefs.edit().putBoolean(STUN_FALLBACK_KEY, enabled).apply()
50+
fun isStunFallbackEnabled(): Boolean = prefs.getBoolean(STUN_FALLBACK_KEY, false)
4451

45-
fun getCameraFacing(): String {
46-
return sharedPreferences.getString(CAMERA_FACING_KEY, "back") ?: "back"
47-
}
52+
// ── Night mode (#6) ───────────────────────────────────────────────────────
53+
fun setNightModeEnabled(enabled: Boolean) = prefs.edit().putBoolean(NIGHT_MODE_KEY, enabled).apply()
54+
fun isNightModeEnabled(): Boolean = prefs.getBoolean(NIGHT_MODE_KEY, false)
4855

49-
fun setQualityPreset(preset: String) {
50-
sharedPreferences.edit()
51-
.putString(QUALITY_KEY, preset)
52-
.apply()
53-
}
56+
// ── Cry detection (#5) ────────────────────────────────────────────────────
57+
fun setCryDetectEnabled(enabled: Boolean) = prefs.edit().putBoolean(CRY_DETECT_KEY, enabled).apply()
58+
fun isCryDetectEnabled(): Boolean = prefs.getBoolean(CRY_DETECT_KEY, true)
5459

55-
fun getQualityPreset(): String {
56-
return sharedPreferences.getString(QUALITY_KEY, "medium") ?: "medium"
57-
}
60+
// ── ntfy (#8) ─────────────────────────────────────────────────────────────
61+
fun setNtfyTopic(topic: String) = prefs.edit().putString(NTFY_TOPIC_KEY, topic).apply()
62+
fun getNtfyTopic(): String = prefs.getString(NTFY_TOPIC_KEY, "") ?: ""
5863

59-
fun setStunFallbackEnabled(enabled: Boolean) {
60-
sharedPreferences.edit()
61-
.putBoolean(STUN_FALLBACK_KEY, enabled)
62-
.apply()
63-
}
64+
fun setNtfyBaseUrl(url: String) = prefs.edit().putString(NTFY_BASE_URL_KEY, url.ifBlank { "https://ntfy.sh" }).apply()
65+
fun getNtfyBaseUrl(): String = prefs.getString(NTFY_BASE_URL_KEY, "https://ntfy.sh") ?: "https://ntfy.sh"
6466

65-
fun isStunFallbackEnabled(): Boolean {
66-
return sharedPreferences.getBoolean(STUN_FALLBACK_KEY, false)
67-
}
68-
}
67+
// ── TURN (#9) ─────────────────────────────────────────────────────────────
68+
fun setTurnUrl(url: String) = prefs.edit().putString(TURN_URL_KEY, url).apply()
69+
fun getTurnUrl(): String = prefs.getString(TURN_URL_KEY, "") ?: ""
70+
71+
fun setTurnUsername(user: String) = prefs.edit().putString(TURN_USERNAME_KEY, user).apply()
72+
fun getTurnUsername(): String = prefs.getString(TURN_USERNAME_KEY, "") ?: ""
73+
74+
fun setTurnCredential(cred: String) = prefs.edit().putString(TURN_CREDENTIAL_KEY, cred).apply()
75+
fun getTurnCredential(): String = prefs.getString(TURN_CREDENTIAL_KEY, "") ?: ""
76+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.ipcamera
2+
3+
import android.media.AudioFormat
4+
import android.media.AudioRecord
5+
import android.media.MediaRecorder
6+
import android.util.Log
7+
8+
/**
9+
* Monitors microphone audio level in a background thread.
10+
* When sustained loud sound (e.g. crying) is detected, calls [onCryDetected].
11+
* When silence is restored, calls [onSilenceRestored].
12+
*
13+
* Note: Creates a secondary AudioRecord (MIC source) alongside WebRTC's own capture.
14+
* On devices that don't support concurrent capture the AudioRecord will fail to
15+
* initialize and cry-detection is silently skipped.
16+
*/
17+
class CryDetector(
18+
private val onCryDetected: () -> Unit,
19+
private val onSilenceRestored: () -> Unit,
20+
) {
21+
private val TAG = "CryDetector"
22+
23+
// ~30 % of full-scale PCM16 amplitude → crying level
24+
private val AMPLITUDE_THRESHOLD = 9_000
25+
// Number of consecutive loud frames before triggering
26+
private val HOLD_FRAMES = 6
27+
// Number of consecutive quiet frames before "silence restored"
28+
private val CALM_FRAMES = 25
29+
30+
private val sampleRate = 8_000
31+
private val bufferSize = AudioRecord.getMinBufferSize(
32+
sampleRate,
33+
AudioFormat.CHANNEL_IN_MONO,
34+
AudioFormat.ENCODING_PCM_16BIT,
35+
).coerceAtLeast(1024)
36+
37+
@Volatile private var running = false
38+
private var detectorThread: Thread? = null
39+
40+
fun start() {
41+
if (running) return
42+
running = true
43+
detectorThread = Thread {
44+
try {
45+
val ar = AudioRecord(
46+
MediaRecorder.AudioSource.MIC,
47+
sampleRate,
48+
AudioFormat.CHANNEL_IN_MONO,
49+
AudioFormat.ENCODING_PCM_16BIT,
50+
bufferSize,
51+
)
52+
if (ar.state != AudioRecord.STATE_INITIALIZED) {
53+
Log.w(TAG, "AudioRecord not initialized — cry detection unavailable on this device")
54+
return@Thread
55+
}
56+
ar.startRecording()
57+
58+
val buf = ShortArray(bufferSize / 2)
59+
var loudCount = 0
60+
var calmCount = 0
61+
var crying = false
62+
63+
while (running) {
64+
val read = ar.read(buf, 0, buf.size)
65+
if (read <= 0) continue
66+
67+
val maxAmp = buf.take(read).maxOf { kotlin.math.abs(it.toInt()) }
68+
69+
if (maxAmp > AMPLITUDE_THRESHOLD) {
70+
loudCount++; calmCount = 0
71+
if (!crying && loudCount >= HOLD_FRAMES) {
72+
crying = true
73+
onCryDetected()
74+
}
75+
} else {
76+
calmCount++; loudCount = 0
77+
if (crying && calmCount >= CALM_FRAMES) {
78+
crying = false
79+
onSilenceRestored()
80+
}
81+
}
82+
}
83+
84+
ar.stop()
85+
ar.release()
86+
} catch (e: Exception) {
87+
Log.e(TAG, "CryDetector thread error", e)
88+
}
89+
}.apply {
90+
name = "CryDetectorThread"
91+
isDaemon = true
92+
start()
93+
}
94+
}
95+
96+
fun stop() {
97+
running = false
98+
detectorThread?.interrupt()
99+
detectorThread = null
100+
}
101+
}

0 commit comments

Comments
 (0)