feat: управление яркостью/WiFi очков через Mercury + накопленные доработки
- Mercury (app-phone): управление яркостью (с авто-выкл. перед применением) и WiFi (скан/подключение/список сетей с паролем); добавлено право BLUETOOTH_ADVERTISE в манифест и runtime-запрос (без него GATT-сервер не рекламируется → очки не подключаются) - логирование (lib-core core.log): BatchingLogCollector + LogSink + LogSpool + LokiLogSink; цепочка очки→телефон→Loki; lib-core log() переадресован в коллектор - STT: Qwen3-ASR через pw.binom.asr:asr-qwen3-android, убран in-house whisper-фоллбек - audio-sync: P-регулятор с насыщением вместо фиксированной скорости - media-split: очки только video, телефон только audio (UI-чипы) - Components: форматирование размера через Locale.ROOT
This commit is contained in:
@@ -31,8 +31,14 @@ internal data class SyncDecision(
|
||||
/** Гистерезис: внутри зоны [-TRIGGER_BAND_MS; +TRIGGER_BAND_MS] скорость 1f — без дребезга. */
|
||||
internal const val TRIGGER_BAND_MS = 150L
|
||||
|
||||
/** Темп догонки/отстаивания за пределами гистерезиса: 1.02 / 0.98 на каждый цикл. */
|
||||
internal const val DRIFT_RATE = 0.02f
|
||||
/**
|
||||
* Коэффициент пропорциональной коррекции: скорость = 1 ± [DRIFT_GAIN]·|diff| (пока diff < гистерезиса — 1f).
|
||||
* 0.0002f/мс → 300мс рассинхрона ≈ 1.06/0.94, 500мс и дальше упирается в [MAX_DRIFT_SPEED].
|
||||
*/
|
||||
internal const val DRIFT_GAIN = 0.0002f
|
||||
|
||||
/** Потолок изменения темпа (±10%): дальше слышно, что «не в свой темп». */
|
||||
internal const val MAX_DRIFT_SPEED = 0.10f
|
||||
|
||||
/** Экстремальный рассинхрон: |diff| > HARD_SEEK_THRESHOLD_MS — скорость не нагонит, жёсткий seekTo. */
|
||||
internal const val HARD_SEEK_THRESHOLD_MS = 2000L
|
||||
@@ -40,8 +46,9 @@ internal const val HARD_SEEK_THRESHOLD_MS = 2000L
|
||||
/**
|
||||
* Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android):
|
||||
* - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп);
|
||||
* - audio отстаёт от video (diff < -band) → speed = 1 + [DRIFT_RATE] (нагоняем);
|
||||
* - audio впереди video (diff > +band) → speed = 1 - [DRIFT_RATE] (тормозим);
|
||||
* - иначе — пропорциональная коррекция: звук отстаёт (diff < 0) → 1 + [DRIFT_GAIN]·|diff| (нагоняем),
|
||||
* звук впереди (diff > 0) → 1 − [DRIFT_GAIN]·|diff| (тормозим); результат ограничивается
|
||||
* [MAX_DRIFT_SPEED] (±10%) — дальше темп не растим, чтобы не звучать чужеродно;
|
||||
* - |diff| > [HARD_SEEK_THRESHOLD_MS] → seekToMs = позиция очков; speed = 1f (жёстко, далее мягко);
|
||||
* - isPlaying очков отличается от состояния аудио → play/pause (как раньше, на speed не влияет).
|
||||
*/
|
||||
@@ -55,9 +62,8 @@ internal fun decideSync(
|
||||
val seekToMs = if (abs(diff) > HARD_SEEK_THRESHOLD_MS) videoPosition else null
|
||||
val speed = when {
|
||||
seekToMs != null -> 1f
|
||||
diff < -TRIGGER_BAND_MS -> 1f + DRIFT_RATE
|
||||
diff > TRIGGER_BAND_MS -> 1f - DRIFT_RATE
|
||||
else -> 1f
|
||||
abs(diff) <= TRIGGER_BAND_MS -> 1f
|
||||
else -> (1f - DRIFT_GAIN * diff.toFloat()).coerceIn(1f - MAX_DRIFT_SPEED, 1f + MAX_DRIFT_SPEED)
|
||||
}
|
||||
val playing = when {
|
||||
videoPlaying == audioPlaying -> null
|
||||
|
||||
@@ -44,12 +44,17 @@ class BtServerTransport(
|
||||
/**
|
||||
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||
* 31+ — BLUETOOTH_CONNECT (SCAN — для сканирования; ADVERTISE — для GATT-сервера
|
||||
* очков, Mercury-управление),
|
||||
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||
*/
|
||||
fun runtimePermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_ADVERTISE,
|
||||
)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import pw.binom.viewmate.core.log.LogEntry
|
||||
import pw.binom.viewmate.core.net.GlassesServerTransport
|
||||
import pw.binom.viewmate.core.phone.GlassesSender
|
||||
import pw.binom.viewmate.core.phone.PhoneState
|
||||
@@ -22,6 +23,7 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import pw.binom.viewmate.core.protocol.Gesture
|
||||
import pw.binom.viewmate.core.protocol.Hello
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.LogBatchMsg
|
||||
import pw.binom.viewmate.core.protocol.PlaybackPosition
|
||||
import pw.binom.viewmate.core.protocol.SttAudio
|
||||
import pw.binom.viewmate.core.protocol.SttCancel
|
||||
@@ -97,6 +99,13 @@ class GlassesHub(
|
||||
*/
|
||||
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Хук на лог-пачки очков: телефон прибавляет записи в свой лог-коллектор
|
||||
* (BatchingLogCollector.recordBatch) — они уйдут на сервер вместе со своими.
|
||||
*/
|
||||
@Volatile
|
||||
var onLogBatch: ((List<LogEntry>) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Стриминговое распознавание речи с очков. Лениво создаётся через
|
||||
* [sttFactory] (модели во filesDir/models); onSilence30s/onAutoFinished
|
||||
@@ -195,6 +204,11 @@ class GlassesHub(
|
||||
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0)
|
||||
}
|
||||
|
||||
is LogBatchMsg -> {
|
||||
log("glasses", "лог-пачка с очков: ${msg.entries.size} записей")
|
||||
onLogBatch?.invoke(msg.entries)
|
||||
}
|
||||
|
||||
is SttAudio -> {
|
||||
val s = stt ?: sttFactory?.invoke()?.also { stt = it }
|
||||
s?.accept(msg.data)
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import android.util.Log
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
|
||||
/**
|
||||
* Единый лог телефона.
|
||||
* На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах
|
||||
* (android.util.Log недоступен — метод без @JvmStatic фолбэкнет в println),
|
||||
* (android.util.Log недоступен — метод без @JvmStatic фолбэктнет в println),
|
||||
* чтобы не падать.
|
||||
*
|
||||
* Плюс лог-коллектор: пачки (свои + пришедшие от очков) уходят на сервер
|
||||
* (HttpLogSink); при сбое — прихраняются в споуле и ретраятся позже.
|
||||
*/
|
||||
@Volatile
|
||||
private var logCollector: BatchingLogCollector? = null
|
||||
|
||||
/** Подключить коллектор (PhoneApp.onCreate). */
|
||||
fun attachLogCollector(collector: BatchingLogCollector) {
|
||||
logCollector = collector
|
||||
}
|
||||
|
||||
fun log(tag: String, message: String) {
|
||||
val full = "[view-mate] $tag $message"
|
||||
runCatching {
|
||||
@@ -15,4 +27,5 @@ fun log(tag: String, message: String) {
|
||||
}.onFailure {
|
||||
println(full)
|
||||
}
|
||||
logCollector?.record(tag, message)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import pw.binom.mercury.DiscoveredGlasses
|
||||
import pw.binom.mercury.RayNeoMercury
|
||||
import pw.binom.mercury.model.MyWifiInfo
|
||||
import pw.binom.mercury.pair.PairManager
|
||||
|
||||
/**
|
||||
* Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE).
|
||||
@@ -38,6 +40,11 @@ object MercuryBridge {
|
||||
val glassesName: String? = null,
|
||||
val statusText: String = "не подключено",
|
||||
val lastError: String? = null,
|
||||
val brightness: Int = 0,
|
||||
val automaticBrightness: Boolean = false,
|
||||
val wifiEnabled: Boolean = false,
|
||||
val wifiSsid: String? = null,
|
||||
val wifiNetworks: List<MyWifiInfo> = emptyList(),
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
@@ -55,6 +62,9 @@ object MercuryBridge {
|
||||
is RayNeoMercury.Event.Connected -> {
|
||||
_state.value = _state.value.copy(connected = true)
|
||||
setStatus("подключено (BLE)")
|
||||
// Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы
|
||||
// ползунок яркости и переключатель WiFi показывали реальные значения.
|
||||
scope.launch { mercury?.pullGeneralStatus() }
|
||||
// Включаем heartbeat очков (каждые 3с они пингуют нас) — иначе
|
||||
// при гашении экрана очки рвут BLE-связь через ~3 секунды.
|
||||
// Повторяем, пока очки не будут готовы (они переподключаются
|
||||
@@ -94,8 +104,20 @@ object MercuryBridge {
|
||||
is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%")
|
||||
is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}")
|
||||
is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line)
|
||||
is RayNeoMercury.Event.GeneralStatusUpdate -> log("status", "brightness=${event.status.brightness} deepSuspend=${event.status.deepSuspend}")
|
||||
is RayNeoMercury.Event.WifiScanResult -> log("wifi", "сетей: ${event.networks.size}")
|
||||
is RayNeoMercury.Event.GeneralStatusUpdate -> {
|
||||
val s = event.status
|
||||
_state.value = _state.value.copy(
|
||||
brightness = s.brightness,
|
||||
automaticBrightness = s.automaticBrightness,
|
||||
wifiEnabled = s.wifiEnabled,
|
||||
wifiSsid = s.wifiInfo?.ssid,
|
||||
)
|
||||
log("status", "brightness=${s.brightness} autoBrightness=${s.automaticBrightness} deepSuspend=${s.deepSuspend} wifi=${s.wifiEnabled} ssid=${s.wifiInfo?.ssid}")
|
||||
}
|
||||
is RayNeoMercury.Event.WifiScanResult -> {
|
||||
_state.value = _state.value.copy(wifiNetworks = event.networks)
|
||||
log("wifi", "сетей: ${event.networks.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,7 +131,8 @@ object MercuryBridge {
|
||||
val m = mercury ?: return false
|
||||
return m.hasPermissions() &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,6 +164,20 @@ object MercuryBridge {
|
||||
setStatus("подключено (клиент)")
|
||||
return true
|
||||
}
|
||||
// Без bond очки не отдают GATT-сервис — спариваем и пробуем ещё раз.
|
||||
setStatus("connect не удался, спариваю…")
|
||||
when (val res = m.pair(glasses)) {
|
||||
is PairManager.PairingResult.Success -> {
|
||||
if (m.connect(glasses, 8000)) {
|
||||
_state.value = _state.value.copy(connected = true, glassesName = glasses.name)
|
||||
setStatus("подключено (клиент, после pair)")
|
||||
return true
|
||||
}
|
||||
}
|
||||
is PairManager.PairingResult.Failed -> {
|
||||
setStatus("спаривание не удалось: ${res.reason}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setError("очки не подключились за ${timeoutMillis / 1000} с")
|
||||
@@ -174,13 +211,68 @@ object MercuryBridge {
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Яркость экрана очков (0..100). Авто-яркость принудительно выключается — иначе очки игнорируют ручное значение. */
|
||||
suspend fun setBrightness(value: Int): Boolean {
|
||||
val v = value.coerceIn(0, 100)
|
||||
// При automaticBrightness=true очки отбрасывают brightness, поэтому выключаем авто заранее.
|
||||
mercury?.setAutoBrightness(false)
|
||||
val ok = mercury?.setBrightness(v) ?: false
|
||||
if (ok) _state.value = _state.value.copy(brightness = v, automaticBrightness = false)
|
||||
setStatus(if (ok) "яркость → $v%" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Авто-яркость очков. */
|
||||
suspend fun setAutoBrightness(enable: Boolean): Boolean {
|
||||
val ok = mercury?.setAutoBrightness(enable) ?: false
|
||||
if (ok) _state.value = _state.value.copy(automaticBrightness = enable)
|
||||
setStatus(if (ok) "авто-яркость → $enable" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Вкл/выкл WiFi-радио очков. */
|
||||
suspend fun wifiToggle(enable: Boolean): Boolean {
|
||||
val ok = mercury?.wifiToggle(enable) ?: false
|
||||
if (ok) _state.value = _state.value.copy(wifiEnabled = enable)
|
||||
setStatus(if (ok) "WiFi → ${if (enable) "вкл" else "выкл"}" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Подключить очки к WiFi-сети (пароль null, если открытая). */
|
||||
suspend fun wifiConnect(ssid: String, password: String?): Boolean {
|
||||
val ok = mercury?.wifiConnect(ssid, password) ?: false
|
||||
setStatus(if (ok) "WiFi: подключение к «$ssid»…" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Забыть сохранённую WiFi-сеть на очках. */
|
||||
suspend fun wifiRemove(ssid: String): Boolean {
|
||||
val ok = mercury?.wifiRemove(ssid) ?: false
|
||||
setStatus(if (ok) "WiFi: забыть «$ssid»" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Сканировать доступные WiFi-сети (результат → state.wifiNetworks). */
|
||||
suspend fun wifiScan(): Boolean {
|
||||
val ok = mercury?.wifiScan() ?: false
|
||||
setStatus(if (ok) "WiFi: сканирование…" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Запросить актуальный статус очков (яркость/WiFi/батарея). */
|
||||
suspend fun pullStatus(): Boolean {
|
||||
val ok = mercury?.pullGeneralStatus() ?: false
|
||||
if (!ok) setStatus("ошибка запроса статуса (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun setStatus(text: String) {
|
||||
log("status", text)
|
||||
_state.value = _state.value.copy(statusText = text, lastError = null)
|
||||
}
|
||||
|
||||
private fun setError(text: String) {
|
||||
Log.e(TAG, text)
|
||||
log("mercury", text)
|
||||
_state.value = _state.value.copy(statusText = text, lastError = text)
|
||||
}
|
||||
|
||||
@@ -193,6 +285,6 @@ object MercuryBridge {
|
||||
}
|
||||
|
||||
private fun log(tag: String, msg: String) {
|
||||
Log.d(TAG, "[$tag] $msg")
|
||||
pw.binom.viewmate.phone.log(tag, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||
import pw.binom.viewmate.core.log.FileLogSpool
|
||||
import pw.binom.viewmate.core.log.LokiConfig
|
||||
import pw.binom.viewmate.core.log.SystemClock
|
||||
import pw.binom.viewmate.core.log.lokiLogSink
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
import pw.binom.viewmate.core.phone.PhoneActions
|
||||
@@ -49,7 +54,6 @@ import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
||||
import pw.binom.viewmate.phone.stt.PhraseRecognizer
|
||||
import pw.binom.viewmate.phone.stt.Qwen3AsrStt
|
||||
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||
import pw.binom.viewmate.phone.stt.WhisperStt
|
||||
|
||||
/**
|
||||
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
|
||||
@@ -84,6 +88,10 @@ class PhoneApp : Application() {
|
||||
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
|
||||
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
|
||||
|
||||
/** Лог-коллектор: свои записи + пришедшие от очков → сервер (спул — filesDir/logspool). */
|
||||
lateinit var logCollector: BatchingLogCollector
|
||||
private set
|
||||
|
||||
/** История общения с ассистентом (SQLite, "assistant.db"). */
|
||||
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
|
||||
|
||||
@@ -345,9 +353,9 @@ class PhoneApp : Application() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
|
||||
* Лениво создаёт стриминговое распознавание (модели Qwen3-ASR во filesDir/models —
|
||||
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
|
||||
* на первый SttAudio; тяжёлое создание Whisper — один раз на процесс.
|
||||
* на первый SttAudio; тяжёлое создание Qwen3-ASR — один раз на процесс.
|
||||
*/
|
||||
private fun ensureStt(): SttStreamer? {
|
||||
synchronized(sttLock) {
|
||||
@@ -357,39 +365,24 @@ class PhoneApp : Application() {
|
||||
val qwenEnc = File(modelsDir, "encoder.int8.onnx")
|
||||
val qwenDec = File(modelsDir, "decoder.int8.onnx")
|
||||
val qwenTok = File(modelsDir, "tokenizer")
|
||||
var stt: PhraseRecognizer
|
||||
if (STT_BACKEND == "qwen3_asr" &&
|
||||
qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory
|
||||
) {
|
||||
stt = Qwen3AsrStt(
|
||||
convFrontendPath = qwenConv.absolutePath,
|
||||
encoderPath = qwenEnc.absolutePath,
|
||||
decoderPath = qwenDec.absolutePath,
|
||||
tokenizerDir = qwenTok.absolutePath,
|
||||
numThreads = 4,
|
||||
hotwords = STT_HOTWORDS,
|
||||
)
|
||||
} else {
|
||||
val encoder = File(modelsDir, "small-encoder.int8.onnx")
|
||||
val decoder = File(modelsDir, "small-decoder.int8.onnx")
|
||||
val tokens = File(modelsDir, "small-tokens.txt")
|
||||
if (!encoder.isFile || !decoder.isFile || !tokens.isFile) {
|
||||
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
||||
return null
|
||||
}
|
||||
stt = WhisperStt(
|
||||
encoderPath = encoder.absolutePath,
|
||||
decoderPath = decoder.absolutePath,
|
||||
tokensPath = tokens.absolutePath,
|
||||
numThreads = 4,
|
||||
)
|
||||
if (!qwenConv.isFile || !qwenEnc.isFile || !qwenDec.isFile || !qwenTok.isDirectory) {
|
||||
log("stt", "модели Qwen3-ASR не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
||||
return null
|
||||
}
|
||||
val stt: PhraseRecognizer = Qwen3AsrStt(
|
||||
convFrontendPath = qwenConv.absolutePath,
|
||||
encoderPath = qwenEnc.absolutePath,
|
||||
decoderPath = qwenDec.absolutePath,
|
||||
tokenizerDir = qwenTok.absolutePath,
|
||||
numThreads = 4,
|
||||
hotwords = STT_HOTWORDS,
|
||||
)
|
||||
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
|
||||
File(filesDir, "stt_dump_phone.raw")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")
|
||||
log("stt", "бэкенд: qwen3_asr (hotwords: $STT_HOTWORDS)")
|
||||
val s = SttStreamer(
|
||||
stt = stt,
|
||||
onSilence30s = {
|
||||
@@ -406,7 +399,7 @@ class PhoneApp : Application() {
|
||||
)
|
||||
sttStreamer = s
|
||||
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
||||
log("stt", "SttStreamer готов ($STT_BACKEND)")
|
||||
log("stt", "SttStreamer готов (qwen3_asr)")
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -452,6 +445,27 @@ class PhoneApp : Application() {
|
||||
nsd.publish()
|
||||
server.hub.sttFactory = { ensureStt() }
|
||||
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
|
||||
|
||||
// Лог-коллектор: свои записи + пачки от очков → Loki. instance в Loki
|
||||
// выставляется по source записи ("phone"/"glasses"), спул — filesDir/logspool.
|
||||
logCollector = BatchingLogCollector(
|
||||
device = "phone",
|
||||
sink = lokiLogSink(),
|
||||
spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 64),
|
||||
clock = SystemClock(),
|
||||
flushIntervalMs = 60_000,
|
||||
maxBatchEntries = 500,
|
||||
)
|
||||
logCollector.start(scope)
|
||||
attachLogCollector(logCollector)
|
||||
pw.binom.viewmate.core.logCollector = logCollector
|
||||
server.hub.onLogBatch = { entries -> logCollector.recordBatch(entries) }
|
||||
log(
|
||||
"server",
|
||||
"лог-коллектор запущен (sink: loki ${LokiConfig().url}, instance по source; " +
|
||||
"спул: filesDir/logspool)",
|
||||
)
|
||||
|
||||
scope.launch { assistantLoop() }
|
||||
|
||||
val lastPositionAt = AtomicLong(0)
|
||||
@@ -534,9 +548,6 @@ class PhoneApp : Application() {
|
||||
lateinit var instance: PhoneApp
|
||||
private set
|
||||
|
||||
/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
|
||||
private const val STT_BACKEND = "qwen3_asr"
|
||||
|
||||
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
|
||||
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
|
||||
}
|
||||
|
||||
@@ -1,51 +1,29 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import com.k2fsa.sherpa.onnx.OfflineModelConfig
|
||||
import com.k2fsa.sherpa.onnx.OfflineQwen3AsrModelConfig
|
||||
import com.k2fsa.sherpa.onnx.OfflineRecognizer
|
||||
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
|
||||
import pw.binom.asr.qwen3.Qwen3Config
|
||||
import pw.binom.asr.qwen3.createQwen3Sherpa
|
||||
|
||||
/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */
|
||||
/** Офлайн-распознавание: asr-qwen3 (sherpa-onnx + Qwen3-ASR-0.6B int8, CPU). */
|
||||
class Qwen3AsrStt(
|
||||
private val convFrontendPath: String,
|
||||
private val encoderPath: String,
|
||||
private val decoderPath: String,
|
||||
private val tokenizerDir: String,
|
||||
private val numThreads: Int = 4,
|
||||
private val hotwords: String = "",
|
||||
convFrontendPath: String,
|
||||
encoderPath: String,
|
||||
decoderPath: String,
|
||||
tokenizerDir: String,
|
||||
numThreads: Int = 4,
|
||||
hotwords: String = "",
|
||||
) : PhraseRecognizer {
|
||||
private val recognizer: OfflineRecognizer = OfflineRecognizer(
|
||||
null,
|
||||
OfflineRecognizerConfig(
|
||||
modelConfig = OfflineModelConfig(
|
||||
qwen3Asr = OfflineQwen3AsrModelConfig(
|
||||
convFrontend = convFrontendPath,
|
||||
encoder = encoderPath,
|
||||
decoder = decoderPath,
|
||||
tokenizer = tokenizerDir,
|
||||
maxTotalLen = 512,
|
||||
maxNewTokens = 128,
|
||||
temperature = 1e-6f,
|
||||
topP = 0.8f,
|
||||
seed = 42,
|
||||
hotwords = hotwords,
|
||||
),
|
||||
numThreads = numThreads,
|
||||
provider = "cpu",
|
||||
modelType = "qwen3_asr",
|
||||
),
|
||||
/** Тяжёлая нативка создаётся один раз; recognize() — single-shot (стрим на вызов). */
|
||||
private val recognizer = createQwen3Sherpa(
|
||||
Qwen3Config(
|
||||
convFrontendPath = convFrontendPath,
|
||||
encoderPath = encoderPath,
|
||||
decoderPath = decoderPath,
|
||||
tokenizerDir = tokenizerDir,
|
||||
numThreads = numThreads,
|
||||
hotwords = hotwords,
|
||||
),
|
||||
)
|
||||
|
||||
/** Распознать 16 кГц mono PCM. Возвращает текст. */
|
||||
override fun recognize(samples: FloatArray): String {
|
||||
val stream = recognizer.createStream()
|
||||
try {
|
||||
stream.acceptWaveform(samples, 16000)
|
||||
recognizer.decode(stream)
|
||||
return recognizer.getResult(stream).text.trim()
|
||||
} finally {
|
||||
stream.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun recognize(samples: FloatArray): String = recognizer.recognize(samples, 16000)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
|
||||
/** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */
|
||||
object SttDebug {
|
||||
|
||||
private const val TAG = "stt"
|
||||
|
||||
/**
|
||||
* Распознаёт [wavPath] по фразам (VAD + Whisper).
|
||||
* Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null.
|
||||
@@ -54,14 +51,14 @@ object SttDebug {
|
||||
log("[stt] ВЕСЬ ТЕКСТ: $all")
|
||||
all
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "[stt] ошибка: ${t.message}", t)
|
||||
log("[stt] ошибка: ${t.message}")
|
||||
t.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun log(msg: String) {
|
||||
Log.i(TAG, msg)
|
||||
pw.binom.viewmate.phone.log("stt", msg)
|
||||
println(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import com.k2fsa.sherpa.onnx.OfflineModelConfig
|
||||
import com.k2fsa.sherpa.onnx.OfflineRecognizer
|
||||
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
|
||||
import com.k2fsa.sherpa.onnx.OfflineWhisperModelConfig
|
||||
|
||||
/** Офлайн-распознавание: sherpa-onnx + Whisper-small (int8), CPU. */
|
||||
class WhisperStt(
|
||||
private val encoderPath: String,
|
||||
private val decoderPath: String,
|
||||
private val tokensPath: String,
|
||||
private val numThreads: Int = 4,
|
||||
private val language: String = "ru",
|
||||
) : PhraseRecognizer {
|
||||
private val recognizer: OfflineRecognizer = OfflineRecognizer(
|
||||
null,
|
||||
OfflineRecognizerConfig(
|
||||
modelConfig = OfflineModelConfig(
|
||||
whisper = OfflineWhisperModelConfig(
|
||||
encoder = encoderPath,
|
||||
decoder = decoderPath,
|
||||
language = language,
|
||||
task = "transcribe",
|
||||
tailPaddings = -1,
|
||||
),
|
||||
tokens = tokensPath,
|
||||
numThreads = numThreads,
|
||||
provider = "cpu",
|
||||
modelType = "whisper",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/** Распознать 16 кГц mono PCM. Возвращает текст. */
|
||||
override fun recognize(samples: FloatArray): String {
|
||||
val stream = recognizer.createStream()
|
||||
try {
|
||||
stream.acceptWaveform(samples, 16000)
|
||||
recognizer.decode(stream)
|
||||
return recognizer.getResult(stream).text.trim()
|
||||
} finally {
|
||||
stream.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import java.util.Locale
|
||||
|
||||
/** Экраны приложения (простая навигация без nav-библиотеки). */
|
||||
enum class Screen {
|
||||
@@ -168,7 +169,7 @@ fun bytesToSize(bytes: Long?): String {
|
||||
val gb = 1024.0 * 1024 * 1024
|
||||
val mb = 1024.0 * 1024
|
||||
return when {
|
||||
bytes >= gb -> "%.2f ГБ".format(bytes / gb)
|
||||
bytes >= gb -> String.format(Locale.ROOT, "%.2f ГБ", bytes / gb)
|
||||
bytes >= mb -> "%d МБ".format((bytes / mb).toLong())
|
||||
else -> "%d КБ".format((bytes / 1024).toLong())
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.core.media.MirrorStatus
|
||||
import pw.binom.viewmate.core.phone.trackFileName
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.phone.FileDownloadState
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
@@ -392,26 +391,28 @@ fun DetailsScreen(
|
||||
keys.forEach { key ->
|
||||
val name = finalFileName(key)
|
||||
val (finalFile, partFile) = trackFiles[key] ?: return@forEach
|
||||
val isVideo = key == "video"
|
||||
val phoneRaw = phoneFileState(finalFile, partFile)
|
||||
val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) {
|
||||
dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id)
|
||||
} else {
|
||||
phoneRaw.second
|
||||
}
|
||||
val glassesState = if (glassesFully) {
|
||||
FileDownloadState.DONE to 100
|
||||
} else {
|
||||
glassesFileState(perFileProgress[name])
|
||||
// Видео лежит на очках, звук — на телефоне: чип только у того устройства, куда файл вообще идёт
|
||||
val state = when {
|
||||
isVideo && glassesFully -> FileDownloadState.DONE to 100
|
||||
isVideo -> glassesFileState(perFileProgress[name])
|
||||
else -> phoneRaw.first to phonePercent
|
||||
}
|
||||
DownloadFileRow(
|
||||
name = name,
|
||||
sizeLabel = fileSizeOrProgress(
|
||||
sizeBytes = knownSizeByName[name],
|
||||
phone = phoneRaw.first to phonePercent,
|
||||
glasses = glassesState,
|
||||
),
|
||||
phone = phoneRaw.first to phonePercent,
|
||||
glasses = glassesState,
|
||||
device = if (isVideo) "очки" else "телефон",
|
||||
sizeLabel = if (state.first == FileDownloadState.DOWNLOADING) {
|
||||
"⏳ ${state.second}%"
|
||||
} else {
|
||||
bytesToSize(knownSizeByName[name])
|
||||
},
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -498,24 +499,13 @@ fun DetailsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Метка «размер/прогресс» для строки файла: при качке — ⏳ N%, иначе размер («--», если размера ещё нет). */
|
||||
private fun fileSizeOrProgress(
|
||||
sizeBytes: Long?,
|
||||
phone: Pair<FileDownloadState, Int>,
|
||||
glasses: Pair<FileDownloadState, Int>,
|
||||
): String = when {
|
||||
phone.first == FileDownloadState.DOWNLOADING -> "⏳ ${phone.second}%"
|
||||
glasses.first == FileDownloadState.DOWNLOADING -> "⏳ ${glasses.second}%"
|
||||
else -> bytesToSize(sizeBytes)
|
||||
}
|
||||
|
||||
/** Строка файла: имя · размер, ниже — чипы «телефон» / «очки». */
|
||||
/** Строка файла: имя · размер, ниже — чип того устройства, куда файл идёт (видео — очки, звук — телефон). */
|
||||
@Composable
|
||||
private fun DownloadFileRow(
|
||||
name: String,
|
||||
device: String,
|
||||
sizeLabel: String,
|
||||
phone: Pair<FileDownloadState, Int>,
|
||||
glasses: Pair<FileDownloadState, Int>,
|
||||
state: Pair<FileDownloadState, Int>,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp)
|
||||
@@ -523,13 +513,9 @@ private fun DownloadFileRow(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("телефон", color = PhoneMuted, fontSize = 9.sp)
|
||||
Text(device, color = PhoneMuted, fontSize = 9.sp)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
FileChip(phone)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text("очки", color = PhoneMuted, fontSize = 9.sp)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
FileChip(glasses)
|
||||
FileChip(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,34 +5,60 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.phone.MercuryBridge
|
||||
|
||||
/**
|
||||
* Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury.
|
||||
* Минимум: найти очки → подключить → погасить/включить экран (deepSuspend).
|
||||
* Управление очками: подключение, экран, яркость и WiFi.
|
||||
*/
|
||||
@Composable
|
||||
fun MercuryPanel() {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by MercuryBridge.state.collectAsState()
|
||||
val busy = false
|
||||
|
||||
var brightness by remember { mutableIntStateOf(state.brightness) }
|
||||
var autoBright by remember { mutableStateOf(state.automaticBrightness) }
|
||||
var wifiOn by remember { mutableStateOf(state.wifiEnabled) }
|
||||
var ssid by remember { mutableStateOf("") }
|
||||
var wifiPass by remember { mutableStateOf("") }
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
|
||||
// Синхронизируем локальные поля со статусом очков (после pullGeneralStatus).
|
||||
LaunchedEffect(state.brightness) { brightness = state.brightness }
|
||||
LaunchedEffect(state.automaticBrightness) { autoBright = state.automaticBrightness }
|
||||
LaunchedEffect(state.wifiEnabled) { wifiOn = state.wifiEnabled }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -43,7 +69,7 @@ fun MercuryPanel() {
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "🛰 Mercury BLE (полевой тест)",
|
||||
text = "🛰 Mercury BLE (управление очками)",
|
||||
color = PhoneText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -65,12 +91,7 @@ fun MercuryPanel() {
|
||||
|
||||
Row(modifier = Modifier.padding(top = 10.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
MercuryBridge.connectFirst()
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
onClick = { scope.launch { MercuryBridge.connectFirst() } },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneBlue,
|
||||
contentColor = PhoneBlueText,
|
||||
@@ -80,9 +101,7 @@ fun MercuryPanel() {
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { MercuryBridge.disconnect() }
|
||||
},
|
||||
onClick = { scope.launch { MercuryBridge.disconnect() } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
@@ -95,9 +114,7 @@ fun MercuryPanel() {
|
||||
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { MercuryBridge.screenOff() }
|
||||
},
|
||||
onClick = { scope.launch { MercuryBridge.screenOff() } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2B2F36),
|
||||
@@ -108,9 +125,7 @@ fun MercuryPanel() {
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { MercuryBridge.screenOn() }
|
||||
},
|
||||
onClick = { scope.launch { MercuryBridge.screenOn() } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneAccent,
|
||||
@@ -120,5 +135,177 @@ fun MercuryPanel() {
|
||||
Text("☀️ включить экран", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Яркость ----
|
||||
Text(
|
||||
text = "Яркость: $brightness%",
|
||||
color = PhoneText,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Slider(
|
||||
value = brightness.toFloat(),
|
||||
onValueChange = { brightness = it.roundToInt() },
|
||||
valueRange = 0f..100f,
|
||||
enabled = state.connected,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.setBrightness(brightness) } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneBlue,
|
||||
contentColor = PhoneBlueText,
|
||||
),
|
||||
) {
|
||||
Text("Применить яркость", fontSize = 12.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Switch(
|
||||
checked = autoBright,
|
||||
onCheckedChange = { nv ->
|
||||
autoBright = nv
|
||||
scope.launch { MercuryBridge.setAutoBrightness(nv) }
|
||||
},
|
||||
enabled = state.connected,
|
||||
)
|
||||
Text("авто-яркость", color = PhoneMuted, fontSize = 11.sp)
|
||||
}
|
||||
|
||||
// ---- WiFi ----
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "WiFi",
|
||||
color = PhoneText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Switch(
|
||||
checked = wifiOn,
|
||||
onCheckedChange = { nv ->
|
||||
wifiOn = nv
|
||||
scope.launch { MercuryBridge.wifiToggle(nv) }
|
||||
},
|
||||
enabled = state.connected,
|
||||
)
|
||||
Text(if (wifiOn) "вкл" else "выкл", color = PhoneMuted, fontSize = 11.sp)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Button(
|
||||
onClick = { scope.launch {
|
||||
if (!state.wifiEnabled) MercuryBridge.wifiToggle(true)
|
||||
scanning = true
|
||||
delay(1500) // дать радио подняться
|
||||
MercuryBridge.wifiScan()
|
||||
delay(5000) // окно ожидания ответа очков
|
||||
scanning = false
|
||||
} },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
contentColor = PhoneMuted,
|
||||
),
|
||||
) {
|
||||
Text(if (scanning) "Сканируем…" else "Сканировать", fontSize = 12.sp)
|
||||
}
|
||||
if (state.wifiSsid != null) {
|
||||
Text(
|
||||
"· ${state.wifiSsid}",
|
||||
color = PhoneBlueText,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = ssid,
|
||||
onValueChange = { ssid = it },
|
||||
label = { Text("SSID сети", fontSize = 11.sp) },
|
||||
singleLine = true,
|
||||
enabled = state.connected,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
)
|
||||
TextField(
|
||||
value = wifiPass,
|
||||
onValueChange = { wifiPass = it },
|
||||
label = { Text("Пароль (пусто = открытая сеть)", fontSize = 11.sp) },
|
||||
singleLine = true,
|
||||
enabled = state.connected,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.wifiConnect(ssid, wifiPass.ifBlank { null }) } },
|
||||
enabled = state.connected && ssid.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneBlue,
|
||||
contentColor = PhoneBlueText,
|
||||
),
|
||||
) {
|
||||
Text("Подключить к сети", fontSize = 12.sp)
|
||||
}
|
||||
|
||||
if (state.wifiNetworks.isEmpty() && !scanning) {
|
||||
Text(
|
||||
"Список пуст: включи WiFi и нажми «Сканировать».",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.wifiNetworks.isNotEmpty()) {
|
||||
Text(
|
||||
"Найдено сетей: ${state.wifiNetworks.size}",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 220.dp).fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
items(state.wifiNetworks) { net ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
"${net.ssid} ${net.level}dBm${if (net.saved) " · сохр." else ""}",
|
||||
color = PhoneText,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { scope.launch {
|
||||
val pwd = if (net.needsPassword()) wifiPass.ifBlank { null } else null
|
||||
MercuryBridge.wifiConnect(net.ssid, pwd)
|
||||
} },
|
||||
enabled = state.connected,
|
||||
) {
|
||||
Text("Подкл.", fontSize = 11.sp)
|
||||
}
|
||||
if (net.saved) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.wifiRemove(net.ssid) } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
contentColor = PhoneMuted,
|
||||
),
|
||||
) {
|
||||
Text("Забыть", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user