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:
@@ -20,11 +20,21 @@ class GlassesApp : Application() {
|
||||
lateinit var movieController: MovieController
|
||||
private set
|
||||
|
||||
/** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */
|
||||
lateinit var downloadManager: GlassesDownloadManager
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
movieController = MovieController(applicationContext)
|
||||
hostConnection = HostConnection(GlassesConfig.HOST_URL, movieController)
|
||||
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
|
||||
hostConnection = HostConnection(
|
||||
url = GlassesConfig.HOST_URL,
|
||||
movieController = movieController,
|
||||
downloadManager = downloadManager,
|
||||
context = applicationContext,
|
||||
)
|
||||
hostConnection.start()
|
||||
log("app", "HostConnection стартует: ${GlassesConfig.HOST_URL}")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import pw.binom.viewmate.core.protocol.DownloadFiles
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||
import pw.binom.viewmate.core.protocol.FileSpec
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// --- чистая логика (тестируется без Android) ---
|
||||
|
||||
/** Имя .part-файла ("video.mkv" → "video.mkv.part"). */
|
||||
internal fun partName(fileName: String): String = "$fileName.part"
|
||||
|
||||
/** Полностью ли скачано на диск: все [fileNames] есть, >0 байт, без .part. */
|
||||
internal fun isFullyOnDisk(dir: File, fileNames: List<String>): Boolean {
|
||||
if (fileNames.isEmpty()) return false
|
||||
return fileNames.all { name ->
|
||||
val final = File(dir, name)
|
||||
final.isFile && final.length() > 0 && !File(dir, partName(name)).exists()
|
||||
}
|
||||
}
|
||||
|
||||
/** OkHttp-запрос на скачивание: при [existingBytes] > 0 докачиваем с Range. */
|
||||
internal fun glassesRequestFor(url: String, existingBytes: Long): Request {
|
||||
val builder = Request.Builder().url(url)
|
||||
if (existingBytes > 0) builder.header("Range", "bytes=$existingBytes-")
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/** Процент одного файла (done/total). */
|
||||
internal fun filePercent(doneBytes: Long, totalBytes: Long): Int {
|
||||
if (totalBytes <= 0) return 0
|
||||
return ((doneBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
/** Общий процент по всем файлам (суммы байтов). */
|
||||
internal fun overallPercent(doneBytes: Map<String, Long>, totalBytes: Map<String, Long>): Int {
|
||||
val total = totalBytes.values.sum()
|
||||
if (total <= 0) return 0
|
||||
val done = doneBytes.values.sum()
|
||||
return ((done * 100) / total).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
// --- персистентность ---
|
||||
|
||||
@Serializable
|
||||
internal data class GlassesState(
|
||||
val expected: Map<String, List<String>> = emptyMap(),
|
||||
val downloaded: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
internal val glassesStateJson: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
/** Запись/чтение status.json очков: атомарно через временный файл. */
|
||||
internal class GlassesStateStore(private val file: File) {
|
||||
fun read(): GlassesState = runCatching {
|
||||
if (!file.exists()) return@runCatching GlassesState()
|
||||
file.inputStream().use { stream ->
|
||||
glassesStateJson.decodeFromString<GlassesState>(stream.readBytes().decodeToString())
|
||||
}
|
||||
}.getOrDefault(GlassesState())
|
||||
|
||||
fun write(state: GlassesState) {
|
||||
try {
|
||||
file.parentFile?.mkdirs()
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
tmp.writeText(glassesStateJson.encodeToString(state))
|
||||
if (!tmp.renameTo(file)) file.writeText(glassesStateJson.encodeToString(state))
|
||||
} catch (e: Exception) {
|
||||
log("glasses", "не удалось сохранить состояние: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Менеджер скачивания контента в память очков (аналог телефонного DownloadManager).
|
||||
* Каталог: filesDir/viewmate/{itemId}/video.mkv + audio-N.ogg. Скачивание пишется в .part
|
||||
* и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send].
|
||||
*/
|
||||
class GlassesDownloadManager(
|
||||
context: Context,
|
||||
private val send: suspend (GlassesToHost) -> Unit,
|
||||
) {
|
||||
val baseDir = File(context.filesDir, "viewmate")
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val jobs = ConcurrentHashMap<String, Job>()
|
||||
private val expectedFiles = ConcurrentHashMap<String, List<String>>()
|
||||
private val stateStore = GlassesStateStore(File(baseDir, "status.json"))
|
||||
|
||||
private val _downloadedIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
/** itemId, у которых контент полностью скачан на очки (переживает рестарт). */
|
||||
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
|
||||
|
||||
private val _mediaBytes = MutableStateFlow(0L)
|
||||
/** Сумма байт полностью скачанных файлов (для GlassesStatus.mediaBytes). */
|
||||
val mediaBytes: StateFlow<Long> = _mediaBytes.asStateFlow()
|
||||
|
||||
private val okHttp = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // длинные файлы, паузы в потоке
|
||||
.build()
|
||||
|
||||
init {
|
||||
loadPersisted()
|
||||
mediaBytesNow()
|
||||
}
|
||||
|
||||
private fun loadPersisted() {
|
||||
val state = stateStore.read()
|
||||
expectedFiles.putAll(state.expected)
|
||||
_downloadedIds.value = state.downloaded.toSet()
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
stateStore.write(
|
||||
GlassesState(
|
||||
expected = expectedFiles.entries.associate { (k, v) -> k to v.toList() },
|
||||
downloaded = _downloadedIds.value.toList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun dirFor(itemId: String): File = File(baseDir, itemId)
|
||||
|
||||
/** Полностью ли скачано зеркало на очки: все ожидаемые файлы есть без .part (список — из DownloadFiles). */
|
||||
fun isFullyDownloaded(itemId: String): Boolean {
|
||||
val names = expectedFiles[itemId] ?: return false
|
||||
return isFullyOnDisk(dirFor(itemId), names)
|
||||
}
|
||||
|
||||
/** Локальный путь к видео для PlayVideo (file://...), null если не скачано. */
|
||||
fun localVideoPath(itemId: String): String? {
|
||||
if (!isFullyDownloaded(itemId)) return null
|
||||
return "file://${File(dirFor(itemId), "video.mkv").absolutePath}"
|
||||
}
|
||||
|
||||
/** Пересчитать mediaBytes с диска и вернуть значение. */
|
||||
fun mediaBytesNow(): Long {
|
||||
var total = 0L
|
||||
baseDir.listFiles()?.forEach { dir ->
|
||||
if (dir.isDirectory) {
|
||||
dir.listFiles { f -> f.isFile && !f.name.endsWith(".part") }?.forEach { total += it.length() }
|
||||
}
|
||||
}
|
||||
_mediaBytes.value = total
|
||||
return total
|
||||
}
|
||||
|
||||
/** Обработка DownloadFiles: скачиваем все файлы последовательно со сквозным прогрессом на телефон. */
|
||||
fun handleDownloadFiles(msg: DownloadFiles) {
|
||||
if (jobs[msg.itemId]?.isActive == true) return
|
||||
val names = msg.files.map { it.name }
|
||||
expectedFiles[msg.itemId] = names
|
||||
persist()
|
||||
|
||||
jobs[msg.itemId] = scope.launch {
|
||||
val done = HashMap<String, Long>()
|
||||
val totals = HashMap<String, Long>()
|
||||
try {
|
||||
val dir = dirFor(msg.itemId)
|
||||
dir.mkdirs()
|
||||
|
||||
if (isFullyOnDisk(dir, names)) {
|
||||
markDownloaded(msg.itemId, true)
|
||||
log("glasses", "${msg.itemId} уже полностью скачан на очки")
|
||||
return@launch
|
||||
}
|
||||
|
||||
msg.files.forEach { spec ->
|
||||
var lastSentAt = 0L
|
||||
val total = downloadFile(
|
||||
spec = spec,
|
||||
part = File(dir, partName(spec.name)),
|
||||
dest = File(dir, spec.name),
|
||||
onTotal = { totals[spec.name] = it },
|
||||
onProgress = { d, t ->
|
||||
done[spec.name] = d
|
||||
totals[spec.name] = t
|
||||
val now = System.currentTimeMillis()
|
||||
// прогресс шлём не чаще раза в полсекунды
|
||||
if (now - lastSentAt >= 500) {
|
||||
lastSentAt = now
|
||||
send(DownloadProgress(msg.itemId, spec.name, filePercent(d, t), d, t, DownloadPhase.DOWNLOADING))
|
||||
}
|
||||
},
|
||||
)
|
||||
done[spec.name] = total
|
||||
totals[spec.name] = total
|
||||
send(DownloadProgress(msg.itemId, spec.name, 100, total, total, DownloadPhase.DONE))
|
||||
}
|
||||
|
||||
markDownloaded(msg.itemId, true)
|
||||
mediaBytesNow()
|
||||
log("glasses", "скачано на очки ${msg.itemId} → $dir")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("glasses", "ошибка скачивания ${msg.itemId}: ${e.message}")
|
||||
val last = done.entries.lastOrNull()
|
||||
send(
|
||||
DownloadProgress(
|
||||
itemId = msg.itemId,
|
||||
fileName = last?.key ?: names.firstOrNull().orEmpty(),
|
||||
percent = last?.let { filePercent(it.value, totals[it.key] ?: 0L) } ?: 0,
|
||||
doneBytes = last?.value ?: 0L,
|
||||
totalBytes = last?.let { totals[it.key] ?: 0L } ?: 0L,
|
||||
phase = DownloadPhase.FAILED,
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
jobs.remove(msg.itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Обработка DeleteFiles: удалить каталог контента на очках. */
|
||||
fun handleDeleteFiles(itemId: String) {
|
||||
jobs.remove(itemId)?.cancel()
|
||||
dirFor(itemId).deleteRecursively()
|
||||
expectedFiles.remove(itemId)
|
||||
markDownloaded(itemId, false)
|
||||
mediaBytesNow()
|
||||
log("glasses", "удалён каталог $itemId с очков")
|
||||
}
|
||||
|
||||
private fun markDownloaded(itemId: String, downloaded: Boolean) {
|
||||
_downloadedIds.value = if (downloaded) _downloadedIds.value + itemId else _downloadedIds.value - itemId
|
||||
persist()
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
|
||||
* по завершении — rename в [dest] (без .part). Возвращает полный размер.
|
||||
*/
|
||||
private suspend fun downloadFile(
|
||||
spec: FileSpec,
|
||||
part: File,
|
||||
dest: File,
|
||||
onProgress: suspend (done: Long, total: Long) -> Unit,
|
||||
onTotal: suspend (Long) -> Unit,
|
||||
): Long {
|
||||
val existing = if (part.exists()) part.length() else 0L
|
||||
okHttp.newCall(glassesRequestFor(spec.url, existing)).execute().use { response ->
|
||||
val code = response.code
|
||||
if (code != 200 && code != 206) error("HTTP $code для ${spec.name}")
|
||||
val body = response.body ?: error("пустое тело ответа для ${spec.name}")
|
||||
|
||||
// 206 — сервер уважил Range; если вернул 200, начинаем заново с нуля
|
||||
val resumed = code == 206 && existing > 0
|
||||
val from = if (resumed) existing else 0L
|
||||
val total = if (spec.sizeBytes > 0) {
|
||||
spec.sizeBytes
|
||||
} else if (resumed) {
|
||||
response.header("Content-Range")?.substringAfterLast('/')?.toLongOrNull()
|
||||
?: (from + body.contentLength())
|
||||
} else {
|
||||
body.contentLength()
|
||||
}
|
||||
onTotal(total)
|
||||
|
||||
val input = body.byteStream()
|
||||
FileOutputStream(part, resumed).use { out ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var done = from
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
if (total > 0) onProgress(done, total)
|
||||
}
|
||||
}
|
||||
|
||||
if (dest.exists()) dest.delete()
|
||||
if (!part.renameTo(dest)) {
|
||||
part.copyTo(dest, overwrite = true)
|
||||
part.delete()
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
@@ -10,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Держатель плеера фильма: создаёт/освобождает SimpleExoPlayer,
|
||||
@@ -30,6 +32,16 @@ class MovieController(private val context: Context) {
|
||||
|
||||
fun playVideo(msg: PlayVideo) {
|
||||
mainHandler.post {
|
||||
// Стриминг отключён полностью: играем ТОЛЬКО локальный файл file://, который есть на диске.
|
||||
if (!msg.videoUrl.startsWith("file://")) {
|
||||
log("glasses", "PlayVideo отклонён: стриминг запрещён, url не локальный (${msg.videoUrl.take(80)})")
|
||||
return@post
|
||||
}
|
||||
val local = Uri.parse(msg.videoUrl).path?.let { File(it) }
|
||||
if (local == null || !local.isFile || local.length() <= 0) {
|
||||
log("glasses", "PlayVideo отклонён: локальный файл не найден (${msg.videoUrl})")
|
||||
return@post
|
||||
}
|
||||
val player = _player ?: SimpleExoPlayer.Builder(context).build().also {
|
||||
_player = it
|
||||
log("glasses", "SimpleExoPlayer создан")
|
||||
|
||||
Reference in New Issue
Block a user