fix(app-phone): звук при watch (диагностика+подстраховка mirrorByItem) + качка не воскресает после удаления

- onWatch: явные логи в каждой точке отказа (watch result / audio source / уже играет / не скачано / mediaDir пуст), свежий mirrorByItem если files не догрузился, звук стартует с позиции очков при уже идущем видео
- resolveAudioSource → resolveAudioSourceDetailed (AudioSourceResult с причиной null)
- DownloadManager: поколения generations — delete() инвалидирует старую корутину (не воскрешает качку), ensureActive перед .part/rename, jobs.remove(itemId, job)
- Тесты 62/0 (в т.ч. поколения — поймали null != 0L)

Инфраструктурный блокер (не код): SeaweedFS на Server3 76.66 в IO-шторме (load ~184, wa 20%, чужой yt-dlp на хосте) — большие GET с s3.binom.pw висят/рвутся с ~14:00; качка на очки FAILED по этой причине.
This commit is contained in:
Hermes Agent
2026-08-19 17:19:53 +03:00
parent fafdddc121
commit 49dac8b33f
29 changed files with 2237 additions and 173 deletions
@@ -1,5 +1,9 @@
package pw.binom.viewmate.glasses
import android.content.Context
import android.os.BatteryManager
import android.os.Environment
import android.os.StatFs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -14,7 +18,10 @@ import kotlinx.coroutines.flow.asStateFlow
import pw.binom.viewmate.core.net.GlassesWsClient
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg
import pw.binom.viewmate.core.protocol.DeleteFiles
import pw.binom.viewmate.core.protocol.DownloadFiles
import pw.binom.viewmate.core.protocol.GlassesStatus
import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.MediaCommandMsg
@@ -27,12 +34,16 @@ import pw.binom.viewmate.core.protocol.Welcome
/**
* Обвязка над [GlassesWsClient] для UI очков:
* реконнект, Hello при подключении, периодический статус/позиция,
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером).
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
* DownloadFiles/DeleteFiles — менеджером скачивания).
*/
class HostConnection(
private val url: String,
private val movieController: MovieController,
private val downloadManager: GlassesDownloadManager,
context: Context,
) {
private val appContext = context.applicationContext
private val client = GlassesWsClient(url)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -53,7 +64,7 @@ class HostConnection(
_connected.value = true
_status.value = "подключено: $url"
addMessage("соединение установлено")
client.send(Hello(appVersion = "0.1.0"))
client.send(Hello(appVersion = APP_VERSION))
},
onDisconnected = {
_connected.value = false
@@ -64,14 +75,44 @@ class HostConnection(
scope.launch {
while (isActive) {
delay(5_000)
// статус/позиция — заглушки (реальные батарея/память — позже)
client.send(GlassesStatus(batteryPercent = 80, storageUsedGb = 10.5, storageTotalGb = 32.0))
client.send(buildStatus())
val (positionMs, playing) = withContext(Dispatchers.Main) { movieController.playbackPosition() }
client.send(PlaybackPosition(positionMs = positionMs, playing = playing))
}
}
}
/** Отправка сообщения на телефон (используется менеджером скачивания). */
suspend fun sendToHost(msg: GlassesToHost) = client.send(msg)
private fun buildStatus(): GlassesStatus {
val (totalBytes, freeBytes) = diskBytes()
val usedBytes = (totalBytes - freeBytes).coerceAtLeast(0L)
val media = downloadManager.mediaBytesNow()
return GlassesStatus(
batteryPercent = batteryPercent(),
storageUsedGb = usedBytes / 1_000_000_000.0,
storageTotalGb = totalBytes / 1_000_000_000.0,
storageTotalBytes = totalBytes,
storageFreeBytes = freeBytes,
mediaBytes = media,
downloadedItemIds = downloadManager.downloadedIds.value.toList(),
mediaDir = downloadManager.baseDir.absolutePath,
appVersion = APP_VERSION,
)
}
private fun batteryPercent(): Int {
val bm = appContext.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
val level = bm?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
return if (level != null && level in 0..100) level else 80
}
private fun diskBytes(): Pair<Long, Long> = runCatching {
val stat = StatFs(Environment.getDataDirectory().absolutePath)
stat.totalBytes to stat.availableBytes
}.getOrDefault(0L to 0L)
private suspend fun handleHostMessage(msg: HostToGlasses) {
when (msg) {
is Welcome -> {
@@ -91,6 +132,16 @@ class HostConnection(
movieController.applyMediaCommand(msg.command, msg.seekMs)
}
is DownloadFiles -> {
addMessage("скачивание на очки: «${msg.title}» (${msg.files.size} файлов)")
downloadManager.handleDownloadFiles(msg)
}
is DeleteFiles -> {
addMessage("удаление с очков: ${msg.itemId}")
downloadManager.handleDeleteFiles(msg.itemId)
}
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
is ShowText -> addMessage("ShowText: ${msg.text}")
@@ -108,4 +159,8 @@ class HostConnection(
client.close()
scope.cancel()
}
companion object {
private const val APP_VERSION = "0.1.0"
}
}