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
@@ -50,26 +50,55 @@ internal fun decideSync(
return SyncDecision(seekToMs = seekToMs, playing = playing)
}
/**
* Результат выбора аудио-источника: [source] (локальный путь или presigned URL)
* либо null с человекочитаемой [reason] — чтобы лог показал точную причину отсутствия звука.
*/
internal data class AudioSourceResult(
val source: String?,
val reason: String,
)
/**
* Источник аудио для просмотра на очках: локальный скачанный `audio-N.ogg`
* (если есть — быстрее, без сети), иначе presigned URL дорожки [audioIndex]
* (стриминг, без скачивания). null — дорожки нет.
* (стриминг, без скачивания). null — дорожки нет, причина в [AudioSourceResult.reason].
* Чистая функция (без Android) — покрыта unit-тестами.
*/
internal fun resolveAudioSourceDetailed(
mirrorFiles: MirrorFiles?,
audioIndex: Int,
localAudioFile: (index: Int) -> File,
presign: (url: String) -> String = { S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION) },
): AudioSourceResult {
if (mirrorFiles == null) {
return AudioSourceResult(null, "files=null (зеркало не получено)")
}
val audio = mirrorFiles.audios.firstOrNull { it.index == audioIndex } ?: mirrorFiles.audios.firstOrNull()
if (audio == null) {
return AudioSourceResult(null, "audios пусто (искал дорожку #$audioIndex)")
}
val local = localAudioFile(audio.index)
return if (local.exists()) {
AudioSourceResult(local.absolutePath, "локальный файл ${local.absolutePath}")
} else {
AudioSourceResult(presign(audio.url), "стриминг (локального audio-${audio.index}.ogg на телефоне нет)")
}
}
/**
* Public-обёртка для UI: источник аудио по [DownloadManager] (S3-presign по умолчанию).
*/
fun resolveAudioSource(
downloadManager: DownloadManager,
itemId: String,
mirrorFiles: MirrorFiles?,
audioIndex: Int,
): String? {
val files = mirrorFiles ?: return null
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull() ?: return null
val local = downloadManager.audioFile(itemId, audio.index)
return if (local.exists()) {
local.absolutePath
} else {
S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
}
}
): String? = resolveAudioSourceDetailed(
mirrorFiles = mirrorFiles,
audioIndex = audioIndex,
localAudioFile = { downloadManager.audioFile(itemId, it) },
).source
/**
* Аудио-плеер телефона, синхронизированный с мастер-таймкодом очков.
@@ -19,6 +19,7 @@ import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.media.MirrorFiles
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.ConcurrentHashMap
@@ -47,6 +48,10 @@ data class DownloadStatus(
val percent: Int = 0,
val totalBytes: Long = 0,
val updatedAt: Long = 0,
/** Ожидаемый набор дорожек: "video", "audio-0", ... — из зеркала, а не из того, что на диске. */
val expectedFiles: List<String> = emptyList(),
/** Размер каждой дорожки (key → байт), заполняется после получения заголовков. */
val fileSizes: Map<String, Long> = emptyMap(),
)
internal val statusJson: Json = Json {
@@ -82,9 +87,96 @@ internal data class DiskScanResult(
)
/**
* Скан папок filesDir/viewmate/<itemId>: финальный video.mkv → itemId в downloaded
* (заодно чистим лишние .part); есть только .part → фаза downloading с percent
* из размера .part и totalBytes из статуса.
* Удалить состояние скачивания [itemId] из всех карт. Идемпотентно:
* повторный вызов ничего не меняет (карты уже не содержат itemId).
*/
internal fun removeDownloadState(
statuses: MutableMap<String, DownloadStatus>,
downloads: MutableMap<String, DownloadProgress>,
downloadedIds: MutableSet<String>,
itemId: String,
) {
downloads.remove(itemId)
downloadedIds.remove(itemId)
statuses.remove(itemId)
}
/**
* Актуальна ли качка поколения [generation] для [itemId]. [delete] увеличивает поколение —
* старая незавершённая корутина после удаления видит несовпадение и не «воскрешает» качку.
* Нет записи — поколение 0 (никогда не удалялось).
*/
internal fun isCurrentGeneration(generations: Map<String, Long>, itemId: String, generation: Long): Boolean =
(generations[itemId] ?: 0L) == generation
/** Имя финального файла для ключа дорожки ("video" → "video.mkv", "audio-0" → "audio-0.ogg"). */
internal fun finalFileName(key: String): String = if (key == "video") "video.mkv" else "$key.ogg"
/** Имя .part-файла для ключа дорожки. */
internal fun partFileName(key: String): String = "${finalFileName(key)}.part"
/** Ключ дорожки по имени .part-файла ("video.mkv.part" → "video", "audio-0.ogg.part" → "audio-0"). */
internal fun fileKeyFor(partName: String): String {
val base = partName.removeSuffix(".part")
return if (base == "video.mkv") "video" else base.removeSuffix(".ogg")
}
/** Список ключей дорожек зеркала: video + каждая аудио (из files.audios, а не с диска). */
internal fun fileKeys(files: MirrorFiles): List<String> =
listOf("video") + files.audios.map { "audio-${it.index}" }
/** Полностью ли скачано на диск: все финальные файлы [keys] есть, >0 байт, без .part. */
internal fun isFullyOnDisk(dir: File, keys: List<String>): Boolean {
if (keys.isEmpty()) return false
return keys.all { key ->
val final = File(dir, finalFileName(key))
final.isFile && final.length() > 0 && !File(dir, partFileName(key)).exists()
}
}
/** Общий процент скачанного на диск по .part/финальным файлам относительно [sizes]. */
internal fun diskPercent(dir: File, keys: List<String>, sizes: Map<String, Long>): Int {
if (keys.isEmpty()) return 0
val totalSum = keys.sumOf { sizes[it] ?: 0L }
if (totalSum <= 0) return 0
var done = 0L
keys.forEach { key ->
val size = sizes[key] ?: 0L
val part = File(dir, partFileName(key))
val final = File(dir, finalFileName(key))
done += when {
part.isFile && part.length() > 0 -> if (size > 0) part.length().coerceAtMost(size) else 0L
final.isFile && final.length() > 0 -> if (size > 0) size else final.length()
else -> 0L
}
}
return ((done * 100) / totalSum).toInt().coerceIn(0, 100)
}
/** Процент одного файла [fileName] по .part/финальному файлу относительно его размера; null, если посчитать нельзя. */
internal fun filePercent(dir: File, fileName: String, sizes: Map<String, Long>): Int? {
val final = File(dir, fileName)
if (final.isFile && final.length() > 0) return 100
val part = File(dir, "$fileName.part")
if (part.isFile && part.length() > 0) {
val total = sizes[fileKeyFor(part.name)] ?: 0L
if (total > 0) return ((part.length() * 100) / total).toInt().coerceIn(0, 99)
}
return null
}
/** Общий процент по живым суммам байтов (скачано/всего по всем файлам). */
internal fun combinedPercent(totals: Map<String, Long>, done: Map<String, Long>): Int {
val totalSum = totals.values.sum()
if (totalSum <= 0) return 0
val doneSum = done.values.sum()
return ((doneSum * 100) / totalSum).toInt().coerceIn(0, 100)
}
/**
* Скан папок filesDir/viewmate/<itemId>: все ожидаемые файлы → itemId в downloaded
* (заодно чистим лишние .part); есть только .part → фаза downloading с общим процентом
* по всем дорожкам. Список дорожек — из статуса (expectedFiles), а не с диска.
*/
internal fun scanDownloads(
baseDir: File,
@@ -96,17 +188,23 @@ internal fun scanDownloads(
baseDir.listFiles()?.forEach { dir ->
if (!dir.isDirectory) return@forEach
val itemId = dir.name
val savedStatus = saved[itemId]
val keys = savedStatus?.expectedFiles.orEmpty()
val parts = dir.listFiles { f -> f.isFile && f.name.endsWith(".part") }?.toList().orEmpty()
val biggestPart = parts.maxByOrNull { it.length() }
val hasFinal = File(dir, "video.mkv").exists()
if (hasFinal) {
// старый формат (без expectedFiles): полным считаем наличие video.mkv
val fully = if (keys.isNotEmpty()) isFullyOnDisk(dir, keys) else File(dir, "video.mkv").isFile
if (fully) {
downloaded += itemId
parts.forEach { it.delete() }
} else if (biggestPart != null && biggestPart.length() > 0) {
val savedStatus = saved[itemId]
val total = savedStatus?.totalBytes ?: 0L
val percent = if (total > 0) ((biggestPart.length() * 100) / total).toInt().coerceIn(0, 99) else 0
} else if (parts.any { it.length() > 0 }) {
val percent = if (keys.isNotEmpty()) {
diskPercent(dir, keys, savedStatus?.fileSizes.orEmpty())
} else {
val total = savedStatus?.totalBytes ?: 0L
val biggest = parts.maxByOrNull { it.length() } ?: return@forEach
if (total > 0) ((biggest.length() * 100) / total).toInt().coerceIn(0, 99) else 0
}
val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "видео"
progress[itemId] = DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, stage)
}
@@ -123,7 +221,7 @@ internal fun requestFor(url: String, existingBytes: Long): Request {
}
/**
* Реальное скачивание зеркала (видео + выбранная озвучка) с прогрессом.
* Реальное скачивание зеркала (видео + все аудио-дорожки) с общим прогрессом.
* Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
* при старте диск сканируется, докачка — только по явному тапу пользователя.
@@ -135,6 +233,13 @@ class DownloadManager(
private val baseDir = File(context.filesDir, "viewmate")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val jobs = ConcurrentHashMap<String, Job>()
/**
* Поколение запуска качки itemId. Растёт при [delete]: незавершённая корутина
* после удаления видит несовпадение поколений и «умирает», не создавая файлы/статус заново.
*/
private val generations = ConcurrentHashMap<String, Long>()
private val statuses = ConcurrentHashMap<String, DownloadStatus>()
private val statusStore = DownloadStatusStore(File(baseDir, "status.json"))
private val okHttp = OkHttpClient.Builder()
@@ -180,6 +285,8 @@ class DownloadManager(
percent = p.percent,
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
updatedAt = System.currentTimeMillis(),
expectedFiles = statuses[itemId]?.expectedFiles ?: emptyList(),
fileSizes = statuses[itemId]?.fileSizes ?: emptyMap(),
)
}
statusStore.write(statuses)
@@ -213,74 +320,122 @@ class DownloadManager(
/** Видео уже скачано на телефон. */
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists()
/** Процент неполного файла [part] от полного размера (totalBytes из статуса). */
fun partPercent(itemId: String, part: File): Int {
if (!part.exists() || part.length() <= 0) return 0
val total = statuses[itemId]?.totalBytes ?: 0L
if (total <= 0) return 0
return ((part.length() * 100) / total).toInt().coerceIn(0, 99)
/**
* Полностью ли скачано зеркало: video.mkv + все аудио из [files] (без .part, >0 байт).
* Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске.
*/
fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean {
val keys = files?.let { fileKeys(it) } ?: return false
if (keys.isEmpty()) return false
return isFullyOnDisk(dirFor(itemId), keys)
}
/**
* Начать (или продолжить) загрузку зеркала [itemId] (видео + озвучка [audioIndex]).
* Если уже качается — повторный вызов игнорируется; если файл полный — тоже.
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-).
*/
fun startDownload(itemId: String, audioIndex: Int) {
if (jobs[itemId]?.isActive == true) return
if (isDownloaded(itemId)) return
/** Общий процент скачанного на диск по всем дорожкам (по .part и полным файлам). */
fun overallPercent(itemId: String): Int {
val st = statuses[itemId] ?: return 0
if (st.expectedFiles.isEmpty()) return 0
return diskPercent(dirFor(itemId), st.expectedFiles, st.fileSizes)
}
jobs[itemId] = scope.launch {
/** Известные размеры дорожек (key → байт) после скачивания на телефон; пусто, если качки не было. */
fun knownFileSizes(itemId: String): Map<String, Long> =
statuses[itemId]?.fileSizes.orEmpty()
/** Процент конкретного файла [fileName] ("video.mkv", "audio-0.ogg") на диске телефона; null, если посчитать нельзя. */
fun percentForFile(itemId: String, fileName: String): Int? {
val st = statuses[itemId]
return filePercent(dirFor(itemId), fileName, st?.fileSizes.orEmpty())
}
/** Процент неполного файла [part] от полного размера дорожки (fileSizes/totalBytes из статуса). */
fun partPercent(itemId: String, part: File): Int =
percentForFile(itemId, part.name.removeSuffix(".part")) ?: 0
/**
* Начать (или продолжить) загрузку зеркала [itemId]: видео + ВСЕ аудио-дорожки.
* Если уже качается — повторный вызов игнорируется; если всё полностью скачано — тоже.
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-) для каждого файла.
*/
fun startDownload(itemId: String) {
if (jobs[itemId]?.isActive == true) return
val generation = generations[itemId] ?: 0L
lateinit var job: Job
job = scope.launch {
_activeIds.value = _activeIds.value + itemId
try {
// корутина «устарела»: itemId удалили (delete) — дальше не идём,
// чтобы не создать заново каталог/статус/.part после удаления
fun stale() = !isCurrentGeneration(generations, itemId, generation)
if (stale()) return@launch
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, resumePercent(itemId), "получаю зеркало"))
val status = mirror.mirrorByItem(itemId)
if (stale()) return@launch
val files = status?.files
if (status == null || status.status != "done" || files == null) {
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
if (!stale()) update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
return@launch
}
val keys = fileKeys(files)
val dir = dirFor(itemId)
if (stale()) return@launch
dir.mkdirs()
downloadFile(
url = S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
part = partVideo(itemId),
dest = videoFile(itemId),
onProgress = { percent ->
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "видео"))
},
onTotal = { total -> setTotalBytes(itemId, total) },
)
if (isFullyOnDisk(dir, keys)) {
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
_downloadedIds.value = _downloadedIds.value + itemId
log("download", "$itemId уже полностью скачан на диск")
return@launch
}
setExpectedFiles(itemId, keys)
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull()
if (audio != null) {
val totals = HashMap<String, Long>()
val done = HashMap<String, Long>()
keys.forEach { key ->
if (stale()) return@launch
val url = if (key == "video") files.video.url
else files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach
val stage = if (key == "video") "видео" else "звук"
downloadFile(
url = S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
part = partAudio(itemId, audio.index),
dest = audioFile(itemId, audio.index),
onProgress = { percent ->
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "звук"))
url = S3Presigner.presignGet(url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
part = File(dir, partFileName(key)),
dest = File(dir, finalFileName(key)),
onTotal = { total ->
totals[key] = total
setFileSize(itemId, key, total)
},
onProgress = { d, t ->
totals[key] = t
done[key] = d
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, combinedPercent(totals, done), stage))
},
onTotal = { total -> setTotalBytes(itemId, total) },
)
if (stale()) return@launch
done[key] = totals[key] ?: 0L
}
if (stale()) return@launch
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
_downloadedIds.value = _downloadedIds.value + itemId
log("download", "скачано $itemId → $dir")
} catch (e: kotlinx.coroutines.CancellationException) {
update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено"))
// после delete() не воскрешаем статус «отменено» — состояние уже сброшено
if (isCurrentGeneration(generations, itemId, generation)) {
update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено"))
}
throw e
} catch (e: Exception) {
log("download", "ошибка скачивания $itemId: ${e.message}")
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
if (isCurrentGeneration(generations, itemId, generation)) {
log("download", "ошибка скачивания $itemId: ${e.message}")
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
}
} finally {
jobs.remove(itemId)
// снимаем ТОЛЬКО свою корутину: после cancel/delete в jobs могла лечь новая
jobs.remove(itemId, job)
_activeIds.value = _activeIds.value - itemId
}
}
jobs[itemId] = job
}
/** Отменить текущую загрузку (.part остаётся — при следующем тапе докачается). */
@@ -288,32 +443,34 @@ class DownloadManager(
jobs.remove(itemId)?.cancel()
}
/** Удалить скачанные файлы с телефона и сбросить статус. */
/** Удалить скачанные файлы с телефона и сбросить статус. Удаление финально. */
fun delete(itemId: String) {
cancel(itemId)
// инвалидируем незавершённую корутину: даже если она проснётся позже — дальше не пойдёт
generations[itemId] = (generations[itemId] ?: 0L) + 1
dirFor(itemId).deleteRecursively()
_downloads.value = _downloads.value - itemId
_downloadedIds.value = _downloadedIds.value - itemId
statuses.remove(itemId)
val downloads = _downloads.value.toMutableMap()
val downloadedIds = _downloadedIds.value.toMutableSet()
removeDownloadState(statuses, downloads, downloadedIds, itemId)
_downloads.value = downloads
_downloadedIds.value = downloadedIds
_activeIds.value = _activeIds.value - itemId
persistStatus(force = true)
log("download", "удалено скачанное $itemId (поколение → ${generations[itemId]})")
}
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке). */
private fun resumePercent(itemId: String): Int {
val total = statuses[itemId]?.totalBytes ?: 0L
if (total <= 0) return 0
return partPercent(itemId, partVideo(itemId))
}
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке) — общий процент по всем дорожкам. */
private fun resumePercent(itemId: String): Int = overallPercent(itemId)
/**
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
* по завершении — rename в [dest] (без .part).
* по завершении — rename в [dest] (без .part). [onProgress] отдаёт (скачано, всего).
*/
private suspend fun downloadFile(
url: String,
part: File,
dest: File,
onProgress: (Int) -> Unit,
onProgress: (done: Long, total: Long) -> Unit,
onTotal: (Long) -> Unit,
) = withContext(Dispatchers.IO) {
val existing = if (part.exists()) part.length() else 0L
@@ -334,6 +491,8 @@ class DownloadManager(
onTotal(total)
val input = body.byteStream()
// перед созданием/открытием .part — стоп, если качку отменили/удалили
currentCoroutineContext().ensureActive()
FileOutputStream(part, resumed).use { out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var done = from
@@ -343,13 +502,13 @@ class DownloadManager(
if (read < 0) break
out.write(buffer, 0, read)
done += read
if (total > 0) {
val percent = ((done * 100) / total).toInt().coerceIn(0, 100)
onProgress(percent)
}
if (total > 0) onProgress(done, total)
}
}
// перед финализацией (.part → конечный файл) — стоп, если качку отменили/удалили,
// иначе удаление «воскреснет» переименованным .part
currentCoroutineContext().ensureActive()
if (dest.exists()) dest.delete()
if (!part.renameTo(dest)) {
part.copyTo(dest, overwrite = true)
@@ -358,22 +517,49 @@ class DownloadManager(
}
}
private fun setTotalBytes(itemId: String, total: Long) {
if (total <= 0) return
private fun setFileSize(itemId: String, key: String, size: Long) {
if (size <= 0) return
val prev = statuses[itemId]
if (prev != null && prev.totalBytes > 0) return
statuses[itemId] = prev?.copy(totalBytes = total) ?: DownloadStatus(totalBytes = total)
val sizes = prev?.fileSizes ?: emptyMap()
if (sizes.containsKey(key)) return
statuses[itemId] = DownloadStatus(
phase = prev?.phase ?: "downloading",
stage = prev?.stage ?: "",
percent = prev?.percent ?: 0,
totalBytes = (prev?.totalBytes ?: 0L) + size,
updatedAt = System.currentTimeMillis(),
expectedFiles = prev?.expectedFiles ?: emptyList(),
fileSizes = sizes + (key to size),
)
persistStatus()
}
private fun setExpectedFiles(itemId: String, keys: List<String>) {
val prev = statuses[itemId]
if (prev?.expectedFiles == keys) return
statuses[itemId] = DownloadStatus(
phase = prev?.phase ?: "downloading",
stage = prev?.stage ?: "",
percent = prev?.percent ?: 0,
totalBytes = prev?.totalBytes ?: 0L,
updatedAt = System.currentTimeMillis(),
expectedFiles = keys,
fileSizes = prev?.fileSizes ?: emptyMap(),
)
persistStatus()
}
private fun update(itemId: String, progress: DownloadProgress) {
_downloads.value = _downloads.value + (itemId to progress)
val prev = statuses[itemId]
statuses[itemId] = DownloadStatus(
phase = progress.phase.name.lowercase(),
stage = progress.stage,
percent = progress.percent,
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
totalBytes = prev?.totalBytes ?: 0L,
updatedAt = System.currentTimeMillis(),
expectedFiles = prev?.expectedFiles ?: emptyList(),
fileSizes = prev?.fileSizes ?: emptyMap(),
)
persistStatus(force = progress.phase != DownloadPhase.DOWNLOADING)
}
@@ -0,0 +1,75 @@
package pw.binom.viewmate.phone
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.core.protocol.DownloadProgress
import java.io.File
/** Статус файла на устройстве (телефон или очки) для строк «скачивание на устройства». */
enum class FileDownloadState {
NONE,
DOWNLOADING,
DONE,
FAILED,
}
/** Статус файла на очках из последнего DownloadProgress (null — не начат). */
fun glassesFileState(progress: DownloadProgress?): Pair<FileDownloadState, Int> {
if (progress == null) return FileDownloadState.NONE to 0
return when (progress.phase) {
DownloadPhase.DONE -> FileDownloadState.DONE to 100
DownloadPhase.DOWNLOADING -> FileDownloadState.DOWNLOADING to progress.percent.coerceIn(0, 99)
DownloadPhase.FAILED -> FileDownloadState.FAILED to 0
}
}
/** Статус файла на телефоне: финальный файл есть → DONE; .part есть → DOWNLOADING. */
fun phoneFileState(finalFile: File, partFile: File): Pair<FileDownloadState, Int> {
if (finalFile.isFile && finalFile.length() > 0) return FileDownloadState.DONE to 100
if (partFile.isFile && partFile.length() > 0) return FileDownloadState.DOWNLOADING to 0
return FileDownloadState.NONE to 0
}
/**
* Общий процент скачивания на очки (для кнопки «Скачать на очки»).
* Сначала — по известным размерам дорожек [knownSizes] (из качки на телефон);
* если размеров нет — по прогрессу уже начатых файлов.
*/
fun glassesOverallPercent(
fileNames: List<String>,
knownSizes: Map<String, Long>,
perFile: Map<String, DownloadProgress>,
): Int? {
if (perFile.isEmpty()) return null
val fullyDone = fileNames.isNotEmpty() &&
perFile.keys.containsAll(fileNames) &&
perFile.values.all { it.phase == DownloadPhase.DONE }
if (fullyDone) return 100
val total = fileNames.sumOf { knownSizes[it] ?: 0L }
if (total > 0) {
val done = fileNames.sumOf { name ->
val p = perFile[name]
when {
p == null -> 0L
p.phase == DownloadPhase.DONE -> if (p.totalBytes > 0) p.totalBytes else knownSizes[name] ?: 0L
else -> p.doneBytes
}
}
return ((done * 100) / total).toInt().coerceIn(0, 99)
}
val totalStarted = perFile.values.sumOf { it.totalBytes }
if (totalStarted <= 0) return null
val doneStarted = perFile.values.sumOf { it.doneBytes }
return ((doneStarted * 100) / totalStarted).toInt().coerceIn(0, 99)
}
/** Простой «живой» процент по текущим прогрессам (для индикатора на экране «Очки»). */
fun glassesLivePercent(perFile: Map<String, DownloadProgress>): Int? {
if (perFile.isEmpty()) return null
if (perFile.values.all { it.phase == DownloadPhase.DONE }) return 100
val total = perFile.values.sumOf { it.totalBytes }
if (total <= 0) return null
val done = perFile.values.sumOf { it.doneBytes }
return ((done * 100) / total).toInt().coerceIn(0, 99)
}
@@ -20,6 +20,7 @@ import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.phone.GlassesSender
import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.DownloadProgress
import pw.binom.viewmate.core.protocol.GlassesOff
import pw.binom.viewmate.core.protocol.GlassesStatus
import pw.binom.viewmate.core.protocol.GlassesToHost
@@ -36,7 +37,8 @@ const val GLASSES_WS_PATH = "/ws/glasses"
/**
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза).
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
* DownloadProgress → карта скачивания на очки).
*/
class GlassesHub(
private val state: PhoneState,
@@ -58,6 +60,29 @@ class GlassesHub(
private val _storageTotalGb = MutableStateFlow<Double?>(null)
val storageTotalGb: StateFlow<Double?> = _storageTotalGb.asStateFlow()
private val _storageTotalBytes = MutableStateFlow<Long?>(null)
val storageTotalBytes: StateFlow<Long?> = _storageTotalBytes.asStateFlow()
private val _storageFreeBytes = MutableStateFlow<Long?>(null)
val storageFreeBytes: StateFlow<Long?> = _storageFreeBytes.asStateFlow()
private val _mediaBytes = MutableStateFlow<Long?>(null)
val mediaBytes: StateFlow<Long?> = _mediaBytes.asStateFlow()
private val _appVersion = MutableStateFlow<String?>(null)
val appVersion: StateFlow<String?> = _appVersion.asStateFlow()
private val _mediaDir = MutableStateFlow<String?>(null)
val mediaDir: StateFlow<String?> = _mediaDir.asStateFlow()
private val _downloadedItemIds = MutableStateFlow<Set<String>>(emptySet())
/** itemId полностью скачанных на очки (из периодического GlassesStatus). */
val downloadedItemIds: StateFlow<Set<String>> = _downloadedItemIds.asStateFlow()
private val _glassesDownloads = MutableStateFlow<Map<String, Map<String, DownloadProgress>>>(emptyMap())
/** Прогресс скачивания на очки: itemId → имя файла → последний DownloadProgress от очков. */
val glassesDownloads: StateFlow<Map<String, Map<String, DownloadProgress>>> = _glassesDownloads.asStateFlow()
private val _positionMs = MutableStateFlow<Long?>(null)
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
@@ -106,7 +131,24 @@ class GlassesHub(
_batteryPercent.value = msg.batteryPercent
_storageUsedGb.value = msg.storageUsedGb
_storageTotalGb.value = msg.storageTotalGb
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
if (msg.storageTotalBytes > 0) _storageTotalBytes.value = msg.storageTotalBytes
if (msg.storageFreeBytes > 0) _storageFreeBytes.value = msg.storageFreeBytes
if (msg.mediaBytes > 0) _mediaBytes.value = msg.mediaBytes
if (msg.mediaDir.isNotBlank()) _mediaDir.value = msg.mediaDir
if (msg.appVersion.isNotBlank()) _appVersion.value = msg.appVersion
_downloadedItemIds.value = msg.downloadedItemIds.toSet()
log(
"glasses",
"статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ, " +
"медиа ${msg.mediaBytes} байт, скачано: ${msg.downloadedItemIds.size}, app=${msg.appVersion}",
)
}
is DownloadProgress -> {
val prev = _glassesDownloads.value[msg.itemId] ?: emptyMap()
val updated = _glassesDownloads.value + (msg.itemId to (prev + (msg.fileName to msg)))
_glassesDownloads.value = updated
log("glasses", "скачивание на очки: ${msg.itemId} ${msg.fileName} ${msg.percent}% (${msg.phase})")
}
is PlaybackPosition -> {
@@ -37,6 +37,7 @@ enum class Screen {
CATALOG,
DETAILS,
GLASSES,
GLASSES_INFO,
CHAT,
}
@@ -161,6 +162,18 @@ fun msToClock(ms: Long): String {
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
}
/** Байты → «1.86 ГБ» / «65 МБ» / «--» если размер неизвестен. */
fun bytesToSize(bytes: Long?): String {
if (bytes == null || bytes <= 0) return "--"
val gb = 1024.0 * 1024 * 1024
val mb = 1024.0 * 1024
return when {
bytes >= gb -> "%.2f ГБ".format(bytes / gb)
bytes >= mb -> "%d МБ".format((bytes / mb).toLong())
else -> "%d КБ".format((bytes / 1024).toLong())
}
}
/** Заглушка-подложка скрина (loading / error / пусто). */
@Composable
fun ScreenPlaceholder(text: String, modifier: Modifier = Modifier) {
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -41,11 +42,21 @@ 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.phone.DownloadPhase
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
import pw.binom.viewmate.phone.PhoneConfig
import pw.binom.viewmate.phone.S3Presigner
import pw.binom.viewmate.phone.fileKeys
import pw.binom.viewmate.phone.finalFileName
import pw.binom.viewmate.phone.glassesFileState
import pw.binom.viewmate.phone.glassesOverallPercent
import pw.binom.viewmate.phone.log
import pw.binom.viewmate.phone.phoneFileState
import pw.binom.viewmate.phone.resolveAudioSource
import pw.binom.viewmate.phone.resolveAudioSourceDetailed
import java.io.File
/** Экран 2 — Детали элемента: озвучка, статус конвертации, скачивание, кнопки. */
@Composable
@@ -63,11 +74,10 @@ fun DetailsScreen(
var refreshKey by remember(item.Id) { mutableStateOf(0) }
var audioIndex by remember(item.Id) { mutableStateOf(app.state.currentAudioIndex) }
val downloadProgress by app.downloadManager.downloads.collectAsState()
val activeIds by app.downloadManager.activeIds.collectAsState()
val downloadedMap by app.state.downloadedFlow.collectAsState()
val localIds by app.downloadManager.downloadedIds.collectAsState()
var audioDownloaded by remember(item.Id) { mutableStateOf(app.downloadManager.audioFiles(item.Id).isNotEmpty()) }
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
// статус зеркала + дорожки; пока конвертируется — опрашиваем
LaunchedEffect(item.Id, refreshKey) {
@@ -82,23 +92,29 @@ fun DetailsScreen(
}
}
// файлы озвучки появляются после завершения загрузки на телефон
LaunchedEffect(item.Id, downloadProgress) {
audioDownloaded = app.downloadManager.audioFiles(item.Id).isNotEmpty()
}
val dm = app.downloadManager
val files = mirrorStatus?.files
val audios = files?.audios.orEmpty()
val effectiveAudioIndex = if (audios.any { it.index == audioIndex }) audioIndex else audios.firstOrNull()?.index ?: 0
val download = downloadProgress[item.Id]
// «качается» только если идёт активное скачивание; восстановленный из статуса .part — не качается
val downloading = activeIds.contains(item.Id)
val videoPart = app.downloadManager.partVideo(item.Id)
val hasVideoPart = videoPart.length() > 0
val audioPartFiles = app.downloadManager.audioParts(item.Id)
val videoPart = dm.partVideo(item.Id)
val hasVideoPart = videoPart.isFile && videoPart.length() > 0
val audioPartFiles = dm.audioParts(item.Id)
val hasAudioPart = audioPartFiles.any { it.length() > 0 }
val videoPartPercent = app.downloadManager.partPercent(item.Id, videoPart)
val audioPartPercent = audioPartFiles.maxOfOrNull { app.downloadManager.partPercent(item.Id, it) } ?: 0
val hasAnyPart = hasVideoPart || hasAudioPart
val fullyDownloaded = dm.isFullyDownloaded(item.Id, files)
val overallPartPercent = dm.overallPercent(item.Id)
// --- статус контента на очках (из расширенного GlassesStatus + DownloadProgress) ---
val keys = files?.let { fileKeys(it) }.orEmpty()
val fileNames = keys.map { finalFileName(it) }
val knownSizeByName = dm.knownFileSizes(item.Id).mapKeys { (k, _) -> finalFileName(k) }
val perFileProgress = glassesDownloads[item.Id].orEmpty()
val glassesFully = glassesDownloadedIds.contains(item.Id)
val downloadingOnGlasses = !glassesFully && perFileProgress.values.any { it.phase == DownloadPhase.DOWNLOADING }
val glassesOverall = glassesOverallPercent(fileNames, knownSizeByName, perFileProgress)
fun onSelectAudio(index: Int) {
audioIndex = index
@@ -114,6 +130,86 @@ fun DetailsScreen(
}
fun onWatch() {
// звук уже играет — повторный тап не должен пересоздавать аудио-плеер.
// state.playing в guard НЕ включаем: он остаётся true после рестарта телефона
// (очки продолжают играть), когда аудио-плеер ещё не создан, и блокировал бы запуск звука.
if (app.audioSync.isActive) {
log("watch", "пропущено: аудио-плеер уже активен (audioSync.isActive=true)")
scope.launch { snackbar.showSnackbar("уже играет") }
return
}
if (!glassesFully) {
log("watch", "пропущено: контент не скачан на очки (glassesFully=false)")
scope.launch { snackbar.showSnackbar("сначала нажми «Скачать на очки»") }
return
}
if (glassesMediaDir.isNullOrBlank()) {
log("watch", "пропущено: очки ещё не сообщили каталог (mediaDir пуст)")
scope.launch { snackbar.showSnackbar("очки ещё не сообщили каталог — подожди") }
return
}
val mediaDir = glassesMediaDir!!
scope.launch {
if (app.server.hub.connected.value == 0) {
log("watch", "пропущено: очки не подключены (connected=0)")
snackbar.showSnackbar("очки не подключены")
return@launch
}
val actions = app.actions
// видео на очках уже играет (последний PlaybackPosition) — PlayVideo не пересылаем,
// чтобы не перезапускать фильм: подключаем только звук с текущей позиции очков
val alreadyPlaying = app.state.playing
if (!alreadyPlaying && actions == null) {
log("watch", "пропущено: телефон ещё не готов (actions=null)")
snackbar.showSnackbar("телефон ещё не готов")
return@launch
}
// PlayVideo — только локальный путь на очках, никаких S3-URL очкам
val localPath = "file://$mediaDir/${item.Id}/video.mkv"
val result: String
if (alreadyPlaying) {
result = "включил (видео уже играет на очках)"
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
} else {
result = try {
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath)
} catch (e: Exception) {
log("watch", "watch упал: ${e.message}")
snackbar.showSnackbar("watch упал: ${e.message}")
return@launch
}
}
log("watch", "watch result: $result")
if (result.contains("включил")) {
// зеркало в композиции могло не догрузиться — подстрахуемся свежим статусом
var mirrorFiles = files
if (mirrorFiles == null) {
mirrorFiles = runCatching { app.mirror.mirrorByItem(item.Id)?.files }.getOrNull()
log("watch", "files в композиции не было — запросил зеркало заново: ${if (mirrorFiles == null) "всё ещё null" else "получено"}")
}
// звук — на телефоне: запускаем ту же дорожку (локально или стриминг)
val audio = resolveAudioSourceDetailed(
mirrorFiles = mirrorFiles,
audioIndex = effectiveAudioIndex,
localAudioFile = { dm.audioFile(item.Id, it) },
)
if (audio.source == null) {
log("watch", "audio source: null — ${audio.reason}")
snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}")
} else {
log("watch", "audio source: ${audio.source} (${audio.reason})")
val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else 0L
app.audioSync.play(audio.source, startPositionMs)
}
} else {
log("watch", "watch отклонён телефоном: $result")
snackbar.showSnackbar(result)
}
}
}
fun onDownloadToGlasses() {
if (glassesFully) return
scope.launch {
if (app.server.hub.connected.value == 0) {
snackbar.showSnackbar("очки не подключены")
@@ -124,33 +220,28 @@ fun DetailsScreen(
snackbar.showSnackbar("телефон ещё не готов")
return@launch
}
val result = actions.watch(item.Id, effectiveAudioIndex) {
val sizes = dm.knownFileSizes(item.Id)
val result = actions.downloadToGlasses(item.Id, sizes = sizes) {
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
}
if (result.contains("включил")) {
// звук — на телефоне: запускаем ту же дорожку (локально или стриминг)
resolveAudioSource(app.downloadManager, item.Id, files, effectiveAudioIndex)?.let { source ->
app.audioSync.play(source, 0L)
}
} else {
snackbar.showSnackbar(result)
}
snackbar.showSnackbar(result)
}
}
fun onDownloadToggle() {
val dm = app.downloadManager
when {
// файл уже полный — не запускаем повторную качку
dm.isDownloaded(item.Id) -> Unit
// всё уже полное — не запускаем повторную качку
dm.isFullyDownloaded(item.Id, files) -> Unit
downloading -> dm.cancel(item.Id)
else -> dm.startDownload(item.Id, effectiveAudioIndex)
else -> dm.startDownload(item.Id)
}
}
fun onDeleteDownloaded() {
app.downloadManager.delete(item.Id)
app.actions?.deleteDownloaded(item.Id)
scope.launch {
app.downloadManager.delete(item.Id)
app.actions?.deleteDownloaded(item.Id)
}
}
fun onOrderMirror() {
@@ -275,29 +366,41 @@ fun DetailsScreen(
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
SectionLabel("скачивание на устройства")
StatusBlock {
val onGlasses = downloadedMap[item.Id] == "очки"
val onPhoneVideo = localIds.contains(item.Id) || download?.phase == DownloadPhase.DONE
val onPhoneAudio = audioDownloaded || download?.phase == DownloadPhase.DONE
StatusLine("🕶️ Очки — видео") {
DeviceChip(downloaded = onGlasses, downloading = false, percent = 0)
}
StatusLine("🕶️ Очки — звук") {
DeviceChip(downloaded = onGlasses, downloading = false, percent = 0)
}
StatusLine("📱 Телефон — видео") {
DeviceChip(
downloaded = onPhoneVideo,
downloading = !onPhoneVideo && hasVideoPart,
percent = download?.percent ?: videoPartPercent,
)
}
StatusLine("📱 Телефон — звук") {
DeviceChip(
downloaded = onPhoneAudio,
downloading = !onPhoneAudio && hasAudioPart,
percent = download?.percent ?: audioPartPercent,
)
if (keys.isEmpty()) {
Text("нет дорожек зеркала", color = PhoneMuted, fontSize = 11.sp)
} else {
val trackFiles: Map<String, Pair<File, File>> = keys.associateWith { key ->
if (key == "video") dm.videoFile(item.Id) to dm.partVideo(item.Id)
else {
val idx = key.removePrefix("audio-").toIntOrNull() ?: 0
dm.audioFile(item.Id, idx) to dm.partAudio(item.Id, idx)
}
}
keys.forEach { key ->
val name = finalFileName(key)
val (finalFile, partFile) = trackFiles[key] ?: return@forEach
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])
}
DownloadFileRow(
name = name,
sizeLabel = fileSizeOrProgress(
sizeBytes = knownSizeByName[name],
phone = phoneRaw.first to phonePercent,
glasses = glassesState,
),
phone = phoneRaw.first to phonePercent,
glasses = glassesState,
)
}
}
Spacer(modifier = Modifier.height(6.dp))
OutlinedButton(
@@ -320,11 +423,37 @@ fun DetailsScreen(
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
Button(
onClick = ::onWatch,
enabled = glassesFully,
modifier = Modifier.fillMaxWidth().height(44.dp),
colors = ButtonDefaults.buttonColors(containerColor = PhoneAccent, contentColor = PhoneBackground),
shape = RoundedCornerShape(10.dp),
) {
Text("▶ Смотреть на очках", fontSize = 13.sp, fontWeight = FontWeight.Bold)
Text(
when {
downloadingOnGlasses -> "⏳ качается на очки, погоди"
else -> "▶ Смотреть на очках"
},
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(6.dp))
Button(
onClick = ::onDownloadToGlasses,
enabled = !glassesFully && !downloadingOnGlasses,
modifier = Modifier.fillMaxWidth().height(44.dp),
colors = ButtonDefaults.buttonColors(containerColor = PhoneSurface, contentColor = PhoneTextDim),
shape = RoundedCornerShape(10.dp),
) {
Text(
when {
glassesFully -> "✓ скачано на очки"
downloadingOnGlasses -> "⏳ качается на очки… ${glassesOverall ?: 0}%"
glassesOverall != null -> "Продолжить на очки ($glassesOverall%)"
else -> "Скачать на очки"
},
fontSize = 13.sp,
)
}
Spacer(modifier = Modifier.height(6.dp))
OutlinedButton(
@@ -338,9 +467,9 @@ fun DetailsScreen(
) {
Text(
when {
localIds.contains(item.Id) -> "✓ скачано на телефон"
fullyDownloaded -> "✓ скачано на телефон"
downloading -> "Отменить"
hasVideoPart -> "Продолжить ($videoPartPercent%)"
hasAnyPart -> "Продолжить ($overallPartPercent%)"
else -> "Скачать на телефон"
},
fontSize = 13.sp,
@@ -356,12 +485,49 @@ 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 DeviceChip(downloaded: Boolean, downloading: Boolean, percent: Int) {
when {
downloaded -> StatusChip("✓ скачано", PhoneGreen, PhoneGreenText)
downloading -> StatusChip("↓ качается… $percent%", PhoneYellow, PhoneBackground)
else -> StatusChip("нет", PhoneSurface, PhoneMuted)
private fun DownloadFileRow(
name: String,
sizeLabel: String,
phone: Pair<FileDownloadState, Int>,
glasses: Pair<FileDownloadState, Int>,
) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp)
Row(
modifier = Modifier.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("телефон", 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)
}
}
}
@Composable
private fun FileChip(state: Pair<FileDownloadState, Int>) {
when (state.first) {
FileDownloadState.DONE -> StatusChip("✓", PhoneGreen, PhoneGreenText)
FileDownloadState.DOWNLOADING -> StatusChip("⏳ ${state.second}%", PhoneYellow, PhoneBackground)
FileDownloadState.FAILED -> StatusChip("✗", PhoneRed, Color.White)
FileDownloadState.NONE -> StatusChip("нет", PhoneSurface, PhoneMuted)
}
}
@@ -0,0 +1,167 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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 pw.binom.viewmate.phone.PhoneApp
import kotlin.math.roundToLong
/** Экран «Информация об очках»: модель, батарея, связь, диск (медиа/свободно/прочее), версия. */
@Composable
fun GlassesInfoScreen(
onBack: () -> Unit,
) {
val hub = PhoneApp.instance.server.hub
val connected by hub.connected.collectAsState()
val battery by hub.batteryPercent.collectAsState()
val totalBytes by hub.storageTotalBytes.collectAsState()
val freeBytes by hub.storageFreeBytes.collectAsState()
val mediaBytes by hub.mediaBytes.collectAsState()
val appVersion by hub.appVersion.collectAsState()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
) {
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp)) {
Text(
text = "← назад",
color = PhoneAccent,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onBack)
.padding(6.dp),
)
}
Text(
text = "Информация об очках",
color = PhoneText,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
StatusBlock {
InfoRow("Модель", "RayNeo X2")
InfoRow(
"Связь",
if (connected > 0) "подключено (WiFi)" else "не подключено",
)
InfoRow("Батарея", "${battery ?: "--"}%")
InfoRow("Версия приложения", appVersion?.takeIf { it.isNotBlank() } ?: "—")
}
Spacer(modifier = Modifier.height(12.dp))
// состояние диска: занято / свободно / занято медиа
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
SectionLabel("диск очков")
StatusBlock {
val total = totalBytes
val free = freeBytes
val media = mediaBytes
if (total == null || total <= 0) {
Text("очки не сообщили память", color = PhoneMuted, fontSize = 11.sp)
} else {
val used = (total - (free ?: 0L)).coerceIn(0L, total)
val mediaClamped = media?.coerceIn(0L, used) ?: 0L
val other = (used - mediaClamped).coerceAtLeast(0L)
// «медиа / свободно / прочее»
Row(
modifier = Modifier
.fillMaxWidth()
.height(10.dp)
.clip(RoundedCornerShape(5.dp))
.background(PhoneLine),
) {
if (mediaClamped > 0) {
Box(
modifier = Modifier
.weight(mediaClamped.toFloat())
.background(PhoneAccent),
)
}
if (other > 0) {
Box(
modifier = Modifier
.weight(other.toFloat())
.background(PhoneRed),
)
}
if ((free ?: 0L) > 0) {
Box(
modifier = Modifier
.weight((free ?: 0L).toFloat())
.background(PhoneGreen),
)
}
}
Spacer(modifier = Modifier.height(10.dp))
InfoRow("Занято", bytesToSize(used))
InfoRow("Свободно", bytesToSize(free ?: 0L))
InfoRow("Занято медиа", bytesToSize(mediaClamped))
InfoRow("Прочее", bytesToSize(other))
Text(
text = "доля медиа на диске: ${percentOf(mediaClamped, total)}%",
color = PhoneMuted,
fontSize = 10.sp,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
}
}
private fun percentOf(part: Long, total: Long): Int {
if (total <= 0) return 0
return ((part * 100.0) / total).roundToLong().toInt().coerceIn(0, 100)
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
modifier = Modifier.weight(1f),
color = PhoneMuted,
fontSize = 11.sp,
)
Text(
text = value,
color = PhoneText,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@@ -37,11 +37,15 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.MediaCommand
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.glassesLivePercent
/** Экран 3 — Статус очков + пульт. */
@Composable
fun GlassesScreen() {
fun GlassesScreen(
onOpenInfo: () -> Unit,
) {
val app = PhoneApp.instance
val scope = rememberCoroutineScope()
@@ -54,12 +58,18 @@ fun GlassesScreen() {
val currentItem by app.state.currentItemFlow.collectAsState()
val playing by app.state.playingFlow.collectAsState()
val mode by app.state.modeFlow.collectAsState()
val downloaded by app.state.downloadedFlow.collectAsState()
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
var showDownloads by remember { mutableStateOf(false) }
val actions = app.actions
// активное скачивание на очки: первое с незавершённым файлом
val activeGlassesItem = glassesDownloads.entries.firstOrNull { (_, perFile) ->
perFile.values.any { it.phase != DownloadPhase.DONE }
}
val activeGlassesPercent = activeGlassesItem?.let { (_, perFile) -> glassesLivePercent(perFile) }
fun sendCommand(cmd: MediaCommand, seekMs: Long = 0) {
val a = actions ?: return
if (connected == 0) return
@@ -98,8 +108,20 @@ fun GlassesScreen() {
audioPositionMs = audioPos,
durationMs = currentItem?.RunTimeTicks?.div(10_000),
playing = playing,
onClick = onOpenInfo,
)
// активная загрузка контента на очки
if (activeGlassesItem != null) {
Text(
text = "загрузка на очки: ${activeGlassesPercent ?: 0}%",
color = PhoneYellow,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -152,6 +174,7 @@ private fun GlassesStatusCard(
audioPositionMs: Long,
durationMs: Long?,
playing: Boolean,
onClick: () -> Unit,
) {
Column(
modifier = Modifier
@@ -159,6 +182,7 @@ private fun GlassesStatusCard(
.padding(horizontal = 14.dp, vertical = 10.dp)
.clip(RoundedCornerShape(12.dp))
.background(PhoneCard)
.clickable(onClick = onClick)
.padding(12.dp),
) {
// связь
@@ -279,14 +303,15 @@ private fun OcuRow(icon: String, content: @Composable () -> Unit) {
}
}
/** Диалог: список скачанного на очках (downloaded → «очки») с удалением. */
/** Диалог: список скачанного на очках (реальный статус от очков) с удалением. */
@Composable
private fun GlassesDownloadsDialog(
app: PhoneApp,
onDismiss: () -> Unit,
) {
val downloaded by app.state.downloadedFlow.collectAsState()
val glassesIds = downloaded.filterValues { it == "очки" }.keys.toList()
val scope = rememberCoroutineScope()
val downloadedIds by app.server.hub.downloadedItemIds.collectAsState()
val glassesIds = downloadedIds.toList()
var names by remember(glassesIds) { mutableStateOf<Map<String, String>>(emptyMap()) }
LaunchedEffect(glassesIds) {
@@ -318,7 +343,9 @@ private fun GlassesDownloadsDialog(
modifier = Modifier.weight(1f),
)
TextButton(onClick = {
app.actions?.deleteDownloaded(id)
scope.launch {
app.actions?.deleteDownloaded(id)
}
}) {
Text("удалить", color = PhoneRedText, fontSize = 11.sp)
}
@@ -32,7 +32,7 @@ fun ViewMateApp() {
Scaffold(
containerColor = PhoneBackground,
bottomBar = {
if (screen != Screen.DETAILS) {
if (screen != Screen.DETAILS && screen != Screen.GLASSES_INFO) {
BottomNavBar(current = screen) { screen = it }
}
},
@@ -62,7 +62,8 @@ fun ViewMateApp() {
}
}
Screen.GLASSES -> GlassesScreen()
Screen.GLASSES -> GlassesScreen(onOpenInfo = { screen = Screen.GLASSES_INFO })
Screen.GLASSES_INFO -> GlassesInfoScreen(onBack = { screen = Screen.GLASSES })
Screen.CHAT -> ChatScreen()
}
}