feat: разделение контента — на телефон только аудио, на очки только видео

Телефон качает audio-N.ogg (audioKeys вместо fileKeys, стадия «звук»),
очки получают DownloadFiles только с video.mkv (buildGlassesFileSpecs).
Watch проверяет готовность обоих: телефон — audioReady(), очки — как
было; отказ с подсказкой «Скачать на телефон/очки». Тесты: lib-core 19,
phone 36.
This commit is contained in:
Hermes Agent
2026-08-20 01:41:43 +03:00
parent 9f1c6735c1
commit 2844d6a3ac
7 changed files with 77 additions and 32 deletions
@@ -125,6 +125,10 @@ internal fun fileKeyFor(partName: String): String {
internal fun fileKeys(files: MirrorFiles): List<String> =
listOf("video") + files.audios.map { "audio-${it.index}" }
/** Ключи аудио-дорожек зеркала (audio-N) — телефон качает только звук. */
internal fun audioKeys(files: MirrorFiles): List<String> =
files.audios.map { "audio-${it.index}" }
/** Полностью ли скачано на диск: все финальные файлы [keys] есть, >0 байт, без .part. */
internal fun isFullyOnDisk(dir: File, keys: List<String>): Boolean {
if (keys.isEmpty()) return false
@@ -205,7 +209,7 @@ internal fun scanDownloads(
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() } ?: "видео"
val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "звук"
progress[itemId] = DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, stage)
}
}
@@ -221,8 +225,8 @@ internal fun requestFor(url: String, existingBytes: Long): Request {
}
/**
* Реальное скачивание зеркала (видео + все аудио-дорожки) с общим прогрессом.
* Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
* Реальное скачивание зеркала (только аудио-дорожки — звук на телефоне) с общим прогрессом.
* Файлы: filesDir/viewmate/{itemId}/audio-{index}.ogg.
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
* при старте диск сканируется, докачка — только по явному тапу пользователя.
*/
@@ -251,7 +255,7 @@ class DownloadManager(
val downloads: StateFlow<Map<String, DownloadProgress>> = _downloads.asStateFlow()
private val _downloadedIds = MutableStateFlow<Set<String>>(emptySet())
/** itemId, у которых видео уже скачано на телефон (переживает рестарт приложения). */
/** itemId, у которых звук уже скачан на телефон (переживает рестарт приложения). */
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
private val _activeIds = MutableStateFlow<Set<String>>(emptySet())
@@ -321,15 +325,25 @@ class DownloadManager(
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists()
/**
* Полностью ли скачано зеркало: video.mkv + все аудио из [files] (без .part, >0 байт).
* Полностью ли скачан звук зеркала на телефон: все аудио из [files] (без .part, >0 байт).
* Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске.
*/
fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean {
val keys = files?.let { fileKeys(it) } ?: return false
val keys = files?.let { audioKeys(it) } ?: return false
if (keys.isEmpty()) return false
return isFullyOnDisk(dirFor(itemId), keys)
}
/**
* Все аудио-дорожки зеркала на диске (можно смотреть: звук будет).
* Список дорожек — из статуса качки (expectedFiles), а не с диска.
*/
fun audioReady(itemId: String): Boolean {
val status = statuses[itemId] ?: return false
if (status.expectedFiles.isEmpty()) return false
return isFullyOnDisk(dirFor(itemId), status.expectedFiles)
}
/** Общий процент скачанного на диск по всем дорожкам (по .part и полным файлам). */
fun overallPercent(itemId: String): Int {
val st = statuses[itemId] ?: return 0
@@ -352,7 +366,7 @@ class DownloadManager(
percentForFile(itemId, part.name.removeSuffix(".part")) ?: 0
/**
* Начать (или продолжить) загрузку зеркала [itemId]: видео + ВСЕ аудио-дорожки.
* Начать (или продолжить) загрузку зеркала [itemId]: ТОЛЬКО аудио-дорожки (звук на телефоне).
* Если уже качается — повторный вызов игнорируется; если всё полностью скачано — тоже.
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-) для каждого файла.
*/
@@ -376,7 +390,7 @@ class DownloadManager(
if (!stale()) update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
return@launch
}
val keys = fileKeys(files)
val keys = audioKeys(files)
val dir = dirFor(itemId)
if (stale()) return@launch
dir.mkdirs()
@@ -393,9 +407,8 @@ class DownloadManager(
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 "звук"
val url = files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach
val stage = "звук"
downloadFile(
url = S3Presigner.presignGet(url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
part = File(dir, partFileName(key)),
@@ -150,6 +150,12 @@ fun DetailsScreen(
scope.launch { snackbar.showSnackbar("сначала нажми «Скачать на очки»") }
return
}
val audioOk = dm.audioReady(item.Id)
if (!audioOk) {
log("watch", "пропущено: звук не скачан на телефон (audioReady=false)")
scope.launch { snackbar.showSnackbar("сначала нажми «Скачать на телефон»") }
return
}
if (glassesMediaDir.isNullOrBlank()) {
log("watch", "пропущено: очки ещё не сообщили каталог (mediaDir пуст)")
scope.launch { snackbar.showSnackbar("очки ещё не сообщили каталог — подожди") }
@@ -179,7 +185,7 @@ fun DetailsScreen(
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
} else {
result = try {
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath)
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath, phoneAudioReady = audioOk)
} catch (e: Exception) {
log("watch", "watch упал: ${e.message}")
snackbar.showSnackbar("watch упал: ${e.message}")