From dec066af00bfc8dd48b44f372dfc025f24be6626 Mon Sep 17 00:00:00 2001 From: yibo Date: Mon, 8 Jun 2026 20:38:42 +0800 Subject: [PATCH] feat(lifelog): 定时不录(quiet hours)— 设定时段自动暂停采集、到点恢复 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt | 8 ++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt | 20 ++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/QuietHours.kt | 40 ++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt | 9 +++++++++ 5 files changed, 134 insertions(+), 19 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/QuietHours.kt diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt index b6a047e..4050ed7 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt @@ -250,6 +250,7 @@ private fun RecordingScreen( private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> Unit, onOpenBlock: (Long) -> Unit) { val running by RecorderState.isRunning.collectAsState() val paused by RecorderState.isPaused.collectAsState() + val quiet by RecorderState.isQuiet.collectAsState() val status by RecorderState.status.collectAsState() val segmentCount by RecorderState.segmentCount.collectAsState() val lastAudioMs by RecorderState.lastAudioMs.collectAsState() @@ -300,16 +301,19 @@ private fun HomeTab(activity: Context, repo: Repository, onOpenTimeline: () -> U val stateLabel = when { !running -> "○ 已停止" paused -> "⏸ 已暂停(模型常驻)" + quiet -> "🌙 静音时段(自动)" else -> "● 录音中" } - Text("$stateLabel $status") + // 稳态文案已在 label 里体现,status 只在加载/下载等阶段额外展示。 + val showStatus = status.isNotBlank() && status !in setOf("录音中", "已暂停", "静音时段") + Text(stateLabel + if (showStatus) " $status" else "") // 已录制秒表:录音中显示当前这段连续采集的累计时长。 if (running) { Text( "⏱ 已录制 ${fmtClock(elapsedMs)}", style = MaterialTheme.typography.headlineSmall, - color = if (paused) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary, + color = if (paused || quiet) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary, ) } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt index 4f4d120..f1b5f7c 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.lemon.lifelog.asr.SpeakerClusterer import com.lemon.lifelog.service.BlockBuffer +import com.lemon.lifelog.service.QuietHours import com.lemon.lifelog.chat.ChatConfig import com.lemon.lifelog.storage.StorageManager import com.lemon.lifelog.summary.SummaryScheduler @@ -62,6 +63,9 @@ fun SettingsScreen(activity: Context) { var blockSilenceMin by remember { mutableStateOf(prefs.getInt(BlockBuffer.KEY_SILENCE_MIN, (BlockBuffer.SILENCE_MS / 60_000).toInt())) } + var quietEnabled by remember { mutableStateOf(QuietHours.enabled(activity)) } + var quietStartMin by remember { mutableStateOf(QuietHours.startMin(activity)) } + var quietEndMin by remember { mutableStateOf(QuietHours.endMin(activity)) } var ttlDays by remember { mutableStateOf(prefs.getInt(StorageManager.KEY_TTL_DAYS, StorageManager.DEFAULT_TTL_DAYS)) } var audioUsage by remember { mutableStateOf(-1L) } // -1 = 计算中 var pruneMsg by remember { mutableStateOf("") } @@ -144,6 +148,22 @@ fun SettingsScreen(activity: Context) { blockSilenceMin = it; prefs.edit().putInt(BlockBuffer.KEY_SILENCE_MIN, it).apply() } Text("改完下次录音服务启动生效(停止再开始 / 重启 app)。", style = MaterialTheme.typography.labelSmall) + DropdownSetting("定时不录(此时段自动暂停)", if (quietEnabled) "已启用" else "关闭", + listOf("关闭" to false, "启用" to true)) { + quietEnabled = it; prefs.edit().putBoolean(QuietHours.KEY_ENABLED, it).apply() + } + if (quietEnabled) { + DropdownSetting("起始时刻", "%02d:00".format(quietStartMin / 60), + (0..23).map { "%02d:00".format(it) to it * 60 }) { + quietStartMin = it; prefs.edit().putInt(QuietHours.KEY_START_MIN, it).apply() + } + DropdownSetting("结束时刻", "%02d:00".format(quietEndMin / 60), + (0..23).map { "%02d:00".format(it) to it * 60 }) { + quietEndMin = it; prefs.edit().putInt(QuietHours.KEY_END_MIN, it).apply() + } + Text("此时段自动暂停采集、到点恢复;跨午夜支持(如 23:00→07:00)。30 秒内生效。", + style = MaterialTheme.typography.labelSmall) + } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)", style = MaterialTheme.typography.bodySmall) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/QuietHours.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/QuietHours.kt new file mode 100644 index 0000000..429ddb9 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/QuietHours.kt @@ -0,0 +1,40 @@ +package com.lemon.lifelog.service + +import android.content.Context +import java.util.Calendar + +/** + * 定时不录(quiet hours):用户配置一个时段,录音服务在此时段自动暂停采集、到点自动恢复。 + * 跨午夜支持(如 23:00→07:00)。配置存 lifelog_prefs,[RecorderService] 每 30s reconcile 时读。 + * + * 时刻用「当天分钟数」(0..1439)表示。start==end 视为空窗(永不静音)。 + */ +object QuietHours { + const val PREFS = "lifelog_prefs" + const val KEY_ENABLED = "quiet_enabled" + const val KEY_START_MIN = "quiet_start_min" + const val KEY_END_MIN = "quiet_end_min" + + const val DEFAULT_START_MIN = 23 * 60 // 23:00 + const val DEFAULT_END_MIN = 7 * 60 // 07:00 + + fun enabled(ctx: Context): Boolean = + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_ENABLED, false) + + fun startMin(ctx: Context): Int = + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_START_MIN, DEFAULT_START_MIN) + + fun endMin(ctx: Context): Int = + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_END_MIN, DEFAULT_END_MIN) + + /** 当前是否处于「不录」时段。未启用或空窗恒 false。 */ + fun isQuietNow(ctx: Context): Boolean { + if (!enabled(ctx)) return false + val start = startMin(ctx) + val end = endMin(ctx) + if (start == end) return false + val cal = Calendar.getInstance() + val now = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE) + return if (start < end) now in start until end else now >= start || now < end + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt index da845d6..a7ee99b 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt @@ -29,7 +29,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch class RecorderService : Service() { @@ -58,6 +60,12 @@ class RecorderService : Service() { @Volatile private var paused = false + /** 实际是否在采集(= 非手动暂停 且 非静音时段)。由 reconcile() 维护。 */ + private var capturing = false + + /** 当前展示状态文案,去重避免每 30s reconcile 重发通知。 */ + private var statusText = "" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var pipelineJob: Job? = null private var wakeLock: PowerManager.WakeLock? = null @@ -105,25 +113,56 @@ class RecorderService : Service() { private fun doPause() { if (paused) return paused = true - capture.stop() - wakeLock?.takeIf { it.isHeld }?.release() - RecorderState.onCapturePaused() - RecorderState.setPaused(true) - RecorderState.setStatus("已暂停") - notify("已暂停") + reconcile() Log.i(TAG, "paused (models kept resident)") } private fun doResume() { if (!paused) return paused = false - acquireWakeLock() + reconcile() + Log.i(TAG, "resumed") + } + + private fun startCapture() { + if (capturing) return capture.start(scope) RecorderState.onCapturing() - RecorderState.setPaused(false) - RecorderState.setStatus("录音中") - notify("录音中") - Log.i(TAG, "resumed") + capturing = true + } + + private fun stopCapture() { + if (!capturing) return + capture.stop() + RecorderState.onCapturePaused() + capturing = false + } + + /** + * 调和实际采集状态:采集 = 非手动暂停 且 非「定时不录」时段。手动暂停彻底释放 wakelock; + * 定时不录时保留 wakelock,让 30s 定时器到点能自动恢复。每 30s 及暂停/恢复时调用。 + */ + private fun reconcile() { + val quiet = QuietHours.isQuietNow(this) + val want = !paused && !quiet + if (want != capturing) { + if (want) { + acquireWakeLock() + startCapture() + } else { + stopCapture() + if (paused) wakeLock?.takeIf { it.isHeld }?.release() else acquireWakeLock() + } + } + RecorderState.setPaused(paused) + RecorderState.setQuiet(quiet) + val st = if (paused) "已暂停" else if (quiet) "静音时段" else "录音中" + if (st != statusText) { + statusText = st + RecorderState.setStatus(st) + notify(if (quiet && !paused) "静音时段(到点自动恢复)" else st) + Log.i(TAG, "reconcile → $st") + } } private fun notify(text: String) { @@ -253,17 +292,20 @@ class RecorderService : Service() { } catch (t: Throwable) { Log.w(TAG, "block ${dir.name} process failed", t) } - RecorderState.setStatus(if (paused) "已暂停" else "录音中") + RecorderState.setStatus(statusText.ifBlank { if (paused) "已暂停" else "录音中" }) } } // 崩溃恢复:把上次没处理完的块重新入队。 BlockBuffer.listPending(File(filesDir, "blocks")).forEach { pendingBlocks.trySend(it) } - // 3. Start capturing (unless paused arrived during model load). - if (!paused) { - capture.start(scope) - RecorderState.onCapturing() - RecorderState.setStatus("录音中") + // 3. Start capturing — 据手动暂停 / 定时不录时段决定(reconcile)。 + reconcile() + // 定时不录:每 30s 调和一次,到点自动停/起采集。 + scope.launch { + while (isActive) { + delay(30_000) + reconcile() + } } Log.i(TAG, "pipeline running (block-based diarization-first)") diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt index eeb2edc..8d5581d 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderState.kt @@ -12,6 +12,10 @@ object RecorderState { private val _isPaused = MutableStateFlow(false) val isPaused: StateFlow = _isPaused + /** 定时不录时段中(自动暂停采集,到点恢复)。 */ + private val _isQuiet = MutableStateFlow(false) + val isQuiet: StateFlow = _isQuiet + private val _segmentCount = MutableStateFlow(0) val segmentCount: StateFlow = _segmentCount @@ -44,6 +48,10 @@ object RecorderState { _isPaused.value = v } + fun setQuiet(v: Boolean) { + _isQuiet.value = v + } + /** 采集开始/恢复:开表。 */ fun onCapturing() { _recordingStartedAt.value = System.currentTimeMillis() @@ -70,6 +78,7 @@ object RecorderState { _lastWallMs.value = 0 _recordingStartedAt.value = 0 _recordingAccumMs.value = 0 + _isQuiet.value = false } fun setStatus(s: String) { -- libgit2 0.21.4