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
+30
View File
@@ -271,3 +271,33 @@ APK `pw.binom.viewmate.glasses` установлен.
## TP-6 Скачивание на телефон (реальное)
- Зеркало done → детали → «Скачать на телефон»: прогресс %, файлы video.mkv + audio-{i}.ogg в filesDir, чипы «скачано». Удаление через «Удалить скачанное».
- ПРОЙДЕН частично (QA: F3 — скачано 1.86 ГБ, хэш совпал, чипы; **удаление файлов кнопкой — НЕ тестировалось**)
## TP-7 Персистентный статус + докачка (19.08)
- Переустановка APK → файлы с диска видны («✓ скачано на телефон», чипы «✓ скачано»), повторная качка невозможна.
- ПРОЙДЕН (QA: PERSIST + дамп после FIX)
- Resume «Продолжить (N%)» из .part — **НЕ тестировалось** (нет .part на стенде)
## TP-8 Звук с синхронизацией (19.08)
- watch → очки играют видео, телефон играет аудио; лог «audio sync: video=N audio=M diff=K», K<1000.
- A1 воспроизведение: ПРОЙДЕН (лог: K=327–358 мс, позиции растут)
- A2 пауза/плей (звук следует): ПРОЙДЕН (пауза — обе замерли, diff=162; плей — рост, diff=162–166)
- A3 ±30с (звук прыгает синхронно): ПРОЙДЕН (+30с: K=928–945; −30с: всплеск 2440 на переходе, стабилизация 172–397)
- ⚠️ Замечание QA: периодически аудио-плеер пересоздаётся без действий пользователя («state озвучка → #N», «audio плеер освобождён») — всплески diff до ~5000 мс, сходится за 10–15с. Причину проверить.
- A4 смена озвучки во время просмотра: **не гонялось**
- A5 CLOSE → звук остановлен: **не гонялось**
- Слуховая сверка с оригиналом — на реальном железе (эталон: /tmp/akira-original.mkv на 88.19)
## Не покрыто (пробелы)
- Удаление скачанного (реальное удаление файлов): ✅ ПРОЙДЕН (OFF3-2, шаг 7 — оба устройства пусты, скачано: 0)
- Автоматика: обрыв связи → пауза видео+звук; восстановление → продолжить (README «Автоматика»)
- Resume из .part («Продолжить (N%)»)
- Бейдж каталога «✓» после фикса приоритета (формально не подтверждён)
- Все кейсы на реальном железе RayNeo X2 (стенд — только эмуляторы)
## Офлайн-контент (этап «очки качают в память», 19.08)
- OFF2: качка на очки — ВСЕ 5 файлов в память очков (медиа 2.44 ГБ, скачано: 1), прогресс в UI (загрузка на очки N%), краш bytesToSize исправлен, кнопка «Смотреть» активна только при полном контенте на очках — ✅ ПРОЙДЕН
- OFF3-2: видео на очках — локальный file:// (позиция растёт ровно 5000мс/5с); пауза/плей/+30с — синхронно (позиции замерли/прыгнули); экран «Информация об очках» (модель RayNeo X2, батарея 100%, диск 3.82/1.98 ГБ, медиа 2.44 ГБ, версия 0.1.0); удаление — с обоих устройств — ✅ ПРОЙДЕН (7/7)
- ⚠️ Дефект A: звук при watch не запускается (нет audio sync строк) — в работе
- ⚠️ Дефект B: самовозобновление качки после «Удалить скачанное» — в работе
- ⚠️ Примечание: лог очков в logcat забивается EGL-спамом (System.out вытесняется за секунды) — надёжный канал телеметрии — logcat ТЕЛЕФОНА
+3
View File
@@ -50,8 +50,11 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(project(":lib-core"))
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// WS-клиент очков (OkHttp — Android-движок)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.websockets)
testImplementation(kotlin("test"))
}
@@ -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 создан")
@@ -0,0 +1,119 @@
package pw.binom.viewmate.glasses
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class GlassesDownloadManagerTest {
private fun tempDir(): File = Files.createTempDirectory("glasses-dm-test").toFile()
private fun newBaseDir(): File {
val base = tempDir()
base.deleteOnExit()
return base
}
@Test
fun isFullyOnDiskRequiresAllFilesNoParts() {
val dir = newBaseDir()
val names = listOf("video.mkv", "audio-0.ogg", "audio-1.ogg")
assertFalse(isFullyOnDisk(dir, names), "пустой каталог — не полный")
File(dir, "video.mkv").writeBytes(ByteArray(10))
assertFalse(isFullyOnDisk(dir, names), "только видео — не полный")
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
assertFalse(isFullyOnDisk(dir, names), "не все аудио — не полный")
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
assertTrue(isFullyOnDisk(dir, names), "все файлы — полный")
}
@Test
fun isFullyOnDiskRejectsPartFiles() {
val dir = newBaseDir()
File(dir, "video.mkv").writeBytes(ByteArray(10))
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
File(dir, "audio-0.ogg.part").writeBytes(ByteArray(2))
assertFalse(isFullyOnDisk(dir, listOf("video.mkv", "audio-0.ogg")), ".part не даёт полного")
}
@Test
fun isFullyOnDiskEmptyListIsFalse() {
assertFalse(isFullyOnDisk(newBaseDir(), emptyList()))
}
@Test
fun partNameAppendsSuffix() {
assertEquals("video.mkv.part", partName("video.mkv"))
assertEquals("audio-0.ogg.part", partName("audio-0.ogg"))
}
@Test
fun requestForAddsRangeFromExistingPart() {
val noResume = glassesRequestFor("https://s3/mirror/video.mkv?sig", 0)
assertNull(noResume.header("Range"), "без .part Range-заголовка быть не должно")
val resume = glassesRequestFor("https://s3/mirror/video.mkv?sig", 1_863_946_240)
assertEquals("bytes=1863946240-", resume.header("Range"))
}
@Test
fun filePercentClampsToRange() {
assertEquals(0, filePercent(0, 0))
assertEquals(50, filePercent(500, 1000))
assertEquals(100, filePercent(1000, 1000))
assertEquals(99, filePercent(999, 1000))
}
@Test
fun overallPercentSumsAcrossFiles() {
val done = mapOf("video.mkv" to 100L, "audio-0.ogg" to 25L, "audio-1.ogg" to 0L)
val total = mapOf("video.mkv" to 100L, "audio-0.ogg" to 50L, "audio-1.ogg" to 50L)
assertEquals(62, overallPercent(done, total)) // 125/200
}
@Test
fun overallPercentZeroWithoutTotals() {
assertEquals(0, overallPercent(mapOf("video.mkv" to 5L), emptyMap()))
}
@Test
fun stateStoreWritesAndReads() {
val file = File(tempDir(), "status.json")
val store = GlassesStateStore(file)
val state = GlassesState(
expected = mapOf("i1" to listOf("video.mkv", "audio-0.ogg")),
downloaded = listOf("i1"),
)
store.write(state)
val read = store.read()
assertEquals(state, read)
assertTrue(file.exists())
}
@Test
fun stateStoreMissingReadsEmpty() {
val store = GlassesStateStore(File(tempDir(), "status.json"))
assertEquals(GlassesState(), store.read())
}
@Test
fun localVideoPathNullWhenNotDownloaded() {
val base = newBaseDir()
val dir = File(base, "i1").apply { mkdirs() }
File(dir, "video.mkv").writeBytes(ByteArray(10))
// без ожидаемого списка файлов isFullyDownloaded = false
assertFalse(isFullyOnDisk(dir, listOf("video.mkv", "audio-0.ogg")))
}
}
@@ -9,6 +9,7 @@ import kotlinx.serialization.json.Json
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
@@ -96,7 +97,11 @@ open class GlassesHub(
}
is GlassesStatus -> {
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ, медиа ${msg.mediaBytes} байт")
}
is DownloadProgress -> {
log("glasses", "скачивание на очки: ${msg.itemId} ${msg.fileName} ${msg.percent}% (${msg.phase})")
}
is PlaybackPosition -> {
@@ -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()
}
}
@@ -1,8 +1,14 @@
package pw.binom.viewmate.phone
import pw.binom.viewmate.core.media.MirrorAudio
import pw.binom.viewmate.core.media.MirrorFiles
import pw.binom.viewmate.core.media.MirrorVideo
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class AudioSyncPlayerTest {
@@ -59,4 +65,70 @@ class AudioSyncPlayerTest {
assertNull(d.seekToMs)
assertNull(d.playing)
}
// --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») ---
private fun mirrorFiles(audios: List<MirrorAudio> = listOf()) = MirrorFiles(
video = MirrorVideo(key = "v", url = "https://s3/mirror/video.mkv"),
audios = audios,
)
private fun audioDir(): File = Files.createTempDirectory("audio-src-test").toFile()
@Test
fun sourceIsNullWhenMirrorFilesMissing() {
val result = resolveAudioSourceDetailed(null, 0, { File("/x", "audio-$it.ogg") }) { "signed:$it" }
assertNull(result.source)
assertTrue(result.reason.contains("null"), "причина должна указывать на отсутствующее зеркало: ${result.reason}")
}
@Test
fun sourceIsNullWhenAudiosEmpty() {
val result = resolveAudioSourceDetailed(mirrorFiles(), 0, { File("/x", "audio-$it.ogg") }) { "signed:$it" }
assertNull(result.source)
assertTrue(result.reason.contains("audios пусто"), "причина должна указывать на пустой список дорожек: ${result.reason}")
}
@Test
fun usesRequestedAudioIndex() {
val dir = audioDir()
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
val audios = listOf(
MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"),
MirrorAudio(index = 1, key = "a1", url = "https://s3/audio-1.ogg"),
)
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 1, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
assertEquals(File(dir, "audio-1.ogg").absolutePath, result.source)
assertTrue(result.reason.contains("локальный файл"), "выбрана именно дорожка #1")
}
@Test
fun fallsBackToFirstAudioWhenIndexMissing() {
val dir = audioDir()
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
val audios = listOf(
MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"),
MirrorAudio(index = 2, key = "a2", url = "https://s3/audio-2.ogg"),
)
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 7, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
assertEquals(File(dir, "audio-0.ogg").absolutePath, result.source, "нет #7 — берём первую дорожку")
}
@Test
fun usesLocalFileWhenDownloaded() {
val dir = audioDir()
val local = File(dir, "audio-3.ogg").apply { writeBytes(ByteArray(10)) }
val audios = listOf(MirrorAudio(index = 3, key = "a3", url = "https://s3/audio-3.ogg"))
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 3, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
assertEquals(local.absolutePath, result.source)
}
@Test
fun streamsPresignedUrlWhenNoLocalFile() {
val dir = audioDir()
val audios = listOf(MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"))
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 0, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
assertEquals("signed:https://s3/audio-0.ogg", result.source)
assertTrue(result.reason.contains("стриминг"), "без локального файла — стриминг: ${result.reason}")
}
}
@@ -1,6 +1,9 @@
package pw.binom.viewmate.phone
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.media.MirrorAudio
import pw.binom.viewmate.core.media.MirrorFiles
import pw.binom.viewmate.core.media.MirrorVideo
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
@@ -115,4 +118,183 @@ class DownloadManagerTest {
assertTrue(result.downloaded.isEmpty())
assertTrue(result.progress.isEmpty())
}
@Test
fun fileKeysFromMirrorCoversVideoAndAllAudios() {
val files = MirrorFiles(
video = MirrorVideo(key = "v", url = "http://v"),
audios = listOf(
MirrorAudio(index = 0, key = "a0", url = "http://a0"),
MirrorAudio(index = 1, key = "a1", url = "http://a1"),
),
)
assertEquals(listOf("video", "audio-0", "audio-1"), fileKeys(files))
}
@Test
fun isFullyOnDiskRequiresAllFilesNoParts() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
val keys = listOf("video", "audio-0", "audio-1")
assertTrue(!isFullyOnDisk(dir, keys), "пустой каталог — не полный")
File(dir, "video.mkv").writeBytes(ByteArray(10))
assertTrue(!isFullyOnDisk(dir, keys), "только видео — не полный")
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
assertTrue(!isFullyOnDisk(dir, keys), "не все аудио — не полный")
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
assertTrue(isFullyOnDisk(dir, keys), "все файлы — полный")
}
@Test
fun isFullyOnDiskRejectsPartFiles() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
File(dir, "video.mkv").writeBytes(ByteArray(10))
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
File(dir, "audio-0.ogg.part").writeBytes(ByteArray(2))
assertTrue(!isFullyOnDisk(dir, listOf("video", "audio-0", "audio-1")), ".part не даёт полного")
}
@Test
fun combinedPercentAveragesAcrossAllFiles() {
val totals = mapOf("video" to 100L, "audio-0" to 50L, "audio-1" to 50L)
val done = mapOf("video" to 100L, "audio-0" to 25L, "audio-1" to 0L)
assertEquals(62, combinedPercent(totals, done)) // 125/200
}
@Test
fun diskPercentCountsPartsAndFinalFiles() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
File(dir, "video.mkv").writeBytes(ByteArray(100)) // видео полностью
File(dir, "audio-0.ogg.part").writeBytes(ByteArray(50)) // аудио докачивается
val sizes = mapOf("video" to 100L, "audio-0" to 100L, "audio-1" to 100L)
assertEquals(50, diskPercent(dir, listOf("video", "audio-0", "audio-1"), sizes)) // 150/300
}
@Test
fun filePercentFromPartAndFinal() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
// ничего нет — процент посчитать нельзя
assertNull(filePercent(dir, "video.mkv", emptyMap()))
// .part: процент от известного размера дорожки
File(dir, "video.mkv.part").writeBytes(ByteArray(50))
assertEquals(50, filePercent(dir, "video.mkv", mapOf("video" to 100L)))
assertNull(filePercent(dir, "video.mkv", emptyMap()), "без размера дорожки процент не считаем")
// полный файл — 100% (даже если размера в статусе нет)
File(dir, "video.mkv").writeBytes(ByteArray(100))
assertEquals(100, filePercent(dir, "video.mkv", emptyMap()))
// аудио не начато
assertNull(filePercent(dir, "audio-0.ogg", mapOf("audio-0" to 100L)))
}
@Test
fun scanWithExpectedFilesMarksPartialWithOverallPercent() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
File(dir, "video.mkv").writeBytes(ByteArray(100))
File(dir, "audio-0.ogg.part").writeBytes(ByteArray(50))
val saved = mapOf(
"i" to DownloadStatus(
phase = "downloading",
expectedFiles = listOf("video", "audio-0", "audio-1"),
fileSizes = mapOf("video" to 100L, "audio-0" to 100L, "audio-1" to 100L),
),
)
val result = scanDownloads(base, saved)
assertTrue(result.downloaded.isEmpty())
assertEquals(DownloadPhase.DOWNLOADING, result.progress["i"]?.phase)
assertEquals(50, result.progress["i"]?.percent)
}
@Test
fun scanWithExpectedFilesMarksFullyDownloadedOnlyWhenAllFiles() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
File(dir, "video.mkv").writeBytes(ByteArray(100))
File(dir, "audio-0.ogg").writeBytes(ByteArray(50))
val saved = mapOf(
"i" to DownloadStatus(expectedFiles = listOf("video", "audio-0", "audio-1")),
)
// нет audio-1 — не полный
assertTrue(scanDownloads(base, saved).downloaded.isEmpty())
File(dir, "audio-1.ogg").writeBytes(ByteArray(50))
val result = scanDownloads(base, saved)
assertEquals(setOf("i"), result.downloaded)
assertTrue(result.progress.isEmpty())
}
@Test
fun fileKeyForMapsPartNamesToTrackKeys() {
assertEquals("video", fileKeyFor("video.mkv.part"))
assertEquals("audio-0", fileKeyFor("audio-0.ogg.part"))
assertEquals("audio-1", fileKeyFor("audio-1.ogg.part"))
assertEquals("video.mkv.part", partFileName("video"))
assertEquals("audio-1.ogg.part", partFileName("audio-1"))
assertEquals("video.mkv", finalFileName("video"))
assertEquals("audio-0.ogg", finalFileName("audio-0"))
}
@Test
fun removeDownloadStateIsIdempotent() {
val statuses = mutableMapOf(
"i1" to DownloadStatus(phase = "downloading", percent = 40),
"i2" to DownloadStatus(phase = "done", percent = 100),
)
val downloads = mutableMapOf(
"i1" to DownloadProgress("i1", DownloadPhase.DOWNLOADING, 40, "видео"),
"i2" to DownloadProgress("i2", DownloadPhase.DONE, 100, "готово"),
)
val downloadedIds = mutableSetOf("i1", "i2")
removeDownloadState(statuses, downloads, downloadedIds, "i1")
assertTrue("i1" !in statuses, "статус удалённого itemId не должен оставаться")
assertTrue("i1" !in downloads, "прогресс удалённого itemId не должен оставаться")
assertTrue("i1" !in downloadedIds)
// чужие элементы не трогаем
assertEquals("done", statuses["i2"]?.phase)
assertTrue("i2" in downloads)
// повторный вызов (и даже третий) — идемпотентен, ничего не меняет
val snapshot = Triple(statuses.toMap(), downloads.toMap(), downloadedIds.toSet())
removeDownloadState(statuses, downloads, downloadedIds, "i1")
removeDownloadState(statuses, downloads, downloadedIds, "i1")
assertEquals(snapshot, Triple(statuses.toMap(), downloads.toMap(), downloadedIds.toSet()))
}
@Test
fun deleteBumpsGenerationInvalidatingRunningDownload() {
val generations = mutableMapOf("i1" to 0L)
val running = 0L
assertTrue(isCurrentGeneration(generations, "i1", running), "до удаления качка актуальна")
assertTrue(isCurrentGeneration(generations, "i2", 0L), "нет записи — поколение 0 актуально")
// delete() увеличивает поколение
generations["i1"] = (generations["i1"] ?: 0L) + 1
assertTrue(
!isCurrentGeneration(generations, "i1", running),
"старая незавершённая корутина после delete должна «умереть», а не воскресить качку",
)
assertTrue(isCurrentGeneration(generations, "i1", 1L), "новая качка после delete актуальна")
assertTrue(isCurrentGeneration(generations, "i2", 0L), "другие itemId не затронуты")
}
}
@@ -0,0 +1,168 @@
package pw.binom.viewmate.phone
import pw.binom.viewmate.core.media.MirrorAudio
import pw.binom.viewmate.core.media.MirrorFiles
import pw.binom.viewmate.core.media.MirrorVideo
import pw.binom.viewmate.core.phone.buildDownloadFiles
import pw.binom.viewmate.core.phone.buildGlassesFileSpecs
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.core.protocol.DownloadProgress
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class GlassesFilesTest {
private fun tempDir(): File = Files.createTempDirectory("glasses-files-test").toFile()
private fun mirrorFiles() = MirrorFiles(
video = MirrorVideo(key = "v", url = "https://s3/mirror/video.mkv"),
audios = listOf(
MirrorAudio(index = 0, key = "a0", title = "Русский", url = "https://s3/mirror/audio-0.ogg"),
MirrorAudio(index = 1, key = "a1", url = "https://s3/mirror/audio-1.ogg"),
),
)
@Test
fun buildDownloadFilesUsesPresignedUrlsAndNames() {
val msg = buildDownloadFiles(
itemId = "movie-1",
title = "Interstellar",
files = mirrorFiles(),
sizes = emptyMap(),
presign = { "$it?sig" },
)
assertEquals("movie-1", msg.itemId)
assertEquals("Interstellar", msg.title)
assertEquals(3, msg.files.size)
assertEquals("video.mkv", msg.files[0].name)
assertEquals("https://s3/mirror/video.mkv?sig", msg.files[0].url)
assertEquals("audio-0.ogg", msg.files[1].name)
assertEquals("audio-1.ogg", msg.files[2].name)
}
@Test
fun buildDownloadFilesFillsKnownSizes() {
val msg = buildDownloadFiles(
itemId = "movie-1",
title = "T",
files = mirrorFiles(),
sizes = mapOf("video" to 1_863_946_240L, "audio-0" to 68_157_440L),
presign = { it },
)
assertEquals(1_863_946_240L, msg.files[0].sizeBytes)
assertEquals(68_157_440L, msg.files[1].sizeBytes)
assertEquals(0L, msg.files[2].sizeBytes)
}
@Test
fun buildGlassesFileSpecsPresignsEveryFile() {
val specs = buildGlassesFileSpecs(
files = mirrorFiles(),
sizes = emptyMap(),
presign = { "signed-$it" },
)
assertEquals(3, specs.size)
assertTrue(specs.all { it.url.startsWith("signed-") })
}
@Test
fun glassesFileStateNullIsNone() {
assertEquals(FileDownloadState.NONE to 0, glassesFileState(null))
}
@Test
fun glassesFileStateMapsPhases() {
val done = DownloadProgress("i", "video.mkv", 100, 10, 10, DownloadPhase.DONE)
assertEquals(FileDownloadState.DONE to 100, glassesFileState(done))
val downloading = DownloadProgress("i", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)
assertEquals(FileDownloadState.DOWNLOADING to 45, glassesFileState(downloading))
val failed = DownloadProgress("i", "audio-0.ogg", 0, 5, 10, DownloadPhase.FAILED)
assertEquals(FileDownloadState.FAILED to 0, glassesFileState(failed))
}
@Test
fun glassesFileStateClampsDownloadingPercent() {
val p = DownloadProgress("i", "video.mkv", 150, 150, 100, DownloadPhase.DOWNLOADING)
assertEquals(FileDownloadState.DOWNLOADING to 99, glassesFileState(p))
}
@Test
fun phoneFileStateFinalExistsIsDone() {
val dir = tempDir()
val final = File(dir, "video.mkv").apply { writeBytes(ByteArray(10)) }
val part = File(dir, "video.mkv.part")
assertEquals(FileDownloadState.DONE to 100, phoneFileState(final, part))
}
@Test
fun phoneFileStatePartIsDownloading() {
val dir = tempDir()
val final = File(dir, "video.mkv")
val part = File(dir, "video.mkv.part").apply { writeBytes(ByteArray(10)) }
assertEquals(FileDownloadState.DOWNLOADING to 0, phoneFileState(final, part))
}
@Test
fun phoneFileStateNoneWhenNothing() {
val dir = tempDir()
assertEquals(FileDownloadState.NONE to 0, phoneFileState(File(dir, "video.mkv"), File(dir, "video.mkv.part")))
}
@Test
fun glassesOverallPercentEmptyIsNull() {
assertNull(glassesOverallPercent(listOf("video.mkv", "audio-0.ogg"), emptyMap(), emptyMap()))
}
@Test
fun glassesOverallPercentAllDoneIsHundred() {
val names = listOf("video.mkv", "audio-0.ogg")
val perFile = mapOf(
"video.mkv" to DownloadProgress("i", "video.mkv", 100, 100, 100, DownloadPhase.DONE),
"audio-0.ogg" to DownloadProgress("i", "audio-0.ogg", 100, 50, 50, DownloadPhase.DONE),
)
assertEquals(100, glassesOverallPercent(names, emptyMap(), perFile))
}
@Test
fun glassesOverallPercentUsesKnownSizes() {
val names = listOf("video.mkv", "audio-0.ogg")
val known = mapOf("video.mkv" to 100L, "audio-0.ogg" to 100L)
val perFile = mapOf(
"video.mkv" to DownloadProgress("i", "video.mkv", 100, 100, 100, DownloadPhase.DONE),
"audio-0.ogg" to DownloadProgress("i", "audio-0.ogg", 25, 25, 100, DownloadPhase.DOWNLOADING),
)
assertEquals(62, glassesOverallPercent(names, known, perFile)) // 125/200
}
@Test
fun glassesOverallPercentFallsBackToStartedTotals() {
val names = listOf("video.mkv", "audio-0.ogg")
val perFile = mapOf(
"video.mkv" to DownloadProgress("i", "video.mkv", 50, 50, 100, DownloadPhase.DOWNLOADING),
)
assertEquals(50, glassesOverallPercent(names, emptyMap(), perFile))
}
@Test
fun glassesLivePercentRejectsEmpty() {
assertNull(glassesLivePercent(emptyMap()))
}
@Test
fun glassesLivePercentAllDoneIsHundred() {
val perFile = mapOf(
"video.mkv" to DownloadProgress("i", "video.mkv", 100, 100, 100, DownloadPhase.DONE),
)
assertEquals(100, glassesLivePercent(perFile))
}
}
@@ -15,6 +15,8 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.core.protocol.DownloadProgress
import pw.binom.viewmate.core.protocol.Gesture
import pw.binom.viewmate.core.protocol.GlassesStatus
import pw.binom.viewmate.core.protocol.GlassesToHost
@@ -112,6 +114,55 @@ class GlassesServerTest {
}
}
@Test
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
}
awaitTrue { hub.glassesDownloads.value["i1"]?.size == 2 }
assertEquals(45, hub.glassesDownloads.value["i1"]?.get("video.mkv")?.percent)
assertEquals(DownloadPhase.DONE, hub.glassesDownloads.value["i1"]?.get("audio-0.ogg")?.phase)
}
@Test
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
send(
json.encodeToString(
GlassesToHost.serializer(),
GlassesStatus(
batteryPercent = 60,
storageUsedGb = 12.5,
storageTotalGb = 32.0,
storageTotalBytes = 34_359_738_368,
storageFreeBytes = 20_971_520_000,
mediaBytes = 1_863_946_240,
downloadedItemIds = listOf("i1", "i2"),
mediaDir = "/data/glasses/files/viewmate",
appVersion = "0.2.0",
),
),
)
}
awaitTrue { hub.downloadedItemIds.value.contains("i1") }
assertEquals(34_359_738_368L, hub.storageTotalBytes.value)
assertEquals(20_971_520_000L, hub.storageFreeBytes.value)
assertEquals(1_863_946_240L, hub.mediaBytes.value)
assertEquals("/data/glasses/files/viewmate", hub.mediaDir.value)
assertEquals("0.2.0", hub.appVersion.value)
}
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (!condition()) {
@@ -0,0 +1,29 @@
package pw.binom.viewmate.phone.ui
import kotlin.test.Test
import kotlin.test.assertEquals
class ComponentsTest {
@Test
fun bytesToSizeKilobytes() {
assertEquals("512 КБ", bytesToSize(512 * 1024L))
}
@Test
fun bytesToSizeMegabytes() {
assertEquals("65 МБ", bytesToSize(65 * 1024L * 1024L))
}
@Test
fun bytesToSizeGigabytes() {
assertEquals("1.86 ГБ", bytesToSize(2_000_000_000L))
}
@Test
fun bytesToSizeUnknownIsDashDash() {
assertEquals("--", bytesToSize(null))
assertEquals("--", bytesToSize(0L))
assertEquals("--", bytesToSize(-1L))
}
}
@@ -0,0 +1,47 @@
package pw.binom.viewmate.core.phone
import pw.binom.viewmate.core.media.MirrorFiles
import pw.binom.viewmate.core.protocol.DownloadFiles
import pw.binom.viewmate.core.protocol.FileSpec
/** Имя файла для ключа дорожки ("video" → "video.mkv", "audio-0" → "audio-0.ogg"). */
fun trackFileName(key: String): String = if (key == "video") "video.mkv" else "$key.ogg"
/** Ключи дорожек зеркала: video + каждая аудио (из files.audios, а не с диска). */
fun trackKeys(files: MirrorFiles): List<String> =
listOf("video") + files.audios.map { "audio-${it.index}" }
/** Собрать список файлов для DownloadFiles на очки: presigned URL, имя файла, размер (если известен). */
fun buildGlassesFileSpecs(
files: MirrorFiles,
sizes: Map<String, Long>,
presign: (String) -> String,
): List<FileSpec> {
val video = FileSpec(
url = presign(files.video.url),
name = trackFileName("video"),
sizeBytes = sizes["video"] ?: 0L,
)
val audios = files.audios.map { a ->
val key = "audio-${a.index}"
FileSpec(
url = presign(a.url),
name = trackFileName(key),
sizeBytes = sizes[key] ?: 0L,
)
}
return listOf(video) + audios
}
/** Полное сообщение DownloadFiles для очков. */
fun buildDownloadFiles(
itemId: String,
title: String,
files: MirrorFiles,
sizes: Map<String, Long>,
presign: (String) -> String,
): DownloadFiles = DownloadFiles(
itemId = itemId,
title = title,
files = buildGlassesFileSpecs(files, sizes, presign),
)
@@ -11,6 +11,8 @@ import pw.binom.viewmate.core.media.MirrorStatus
import pw.binom.viewmate.core.protocol.AssistantState
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.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode
@@ -83,25 +85,58 @@ class PhoneActions(
// --- включить на очках ---
/** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */
suspend fun watch(itemId: String, audioIndex: Int = 0, presign: (String) -> String = { it }): String {
/**
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
*/
suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = ""): String {
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val files = status?.files
if (status == null || status.status != "done" || files == null) {
return "зеркало для «${item.Name ?: itemId}» не готово (${status?.status ?: "нет"}). Закажите: mirror $itemId"
}
if (!localVideoPath.startsWith("file://")) {
return "«${item.Name ?: itemId}» не скачан на очки — сначала «Скачать на очки»"
}
val title = item.Name ?: itemId
val videoUrl = presign(files.video.url)
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = videoUrl, audioIndex = audioIndex))
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex))
state.mode = GlassesMode.MOVIE
state.currentItem = item
state.currentAudioIndex = audioIndex
state.mirrorStatus = status
state.playing = true
state.downloaded[itemId] = "очки"
return "включил «$title» на очках (озвучка #$audioIndex): $videoUrl"
return "включил «$title» на очках из локального файла (озвучка #$audioIndex)"
}
/**
* Скачать контент в память очков: DownloadFiles с presigned URL всех файлов
* (video.mkv + audio-N.ogg) и известными размерами дорожек [sizes] (key → байт).
*/
suspend fun downloadToGlasses(
itemId: String,
sizes: Map<String, Long> = emptyMap(),
presign: (String) -> String = { it },
): String {
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val files = status?.files
if (status == null || status.status != "done" || files == null) {
return "зеркало для «${item.Name ?: itemId}» не готово (${status?.status ?: "нет"})"
}
val title = item.Name ?: itemId
val msg = buildDownloadFiles(
itemId = itemId,
title = title,
files = files,
sizes = sizes,
presign = presign,
)
sender.send(msg)
log("phone", "скачивание на очки: $itemId (${msg.files.size} файлов)")
return "загрузка «$title» на очки"
}
// --- зеркала ---
@@ -146,11 +181,12 @@ class PhoneActions(
return "скачано $itemId → $target (эмуляция, отмечено в стейте)"
}
/** Удалить скачанное (эмуляция). */
fun deleteDownloaded(itemId: String): String {
/** Удалить скачанное: DeleteFiles на очки (каталог контента) + снять эмуляционную пометку. */
suspend fun deleteDownloaded(itemId: String): String {
sender.send(DeleteFiles(itemId))
val removed = state.downloaded.remove(itemId)
return if (removed != null) "удалено скачанное: $itemId ($removed)"
else "не было скачано: $itemId"
else "не было скачано (эмуляция): $itemId"
}
// --- сессии ---
@@ -33,11 +33,34 @@ data class MediaCommandMsg(val command: MediaCommand, val seekMs: Long = 0) : Ho
data class PlayVideo(
val itemId: String,
val title: String,
/** ЛОКАЛЬНЫЙ путь на очках (file:///...) ИЛИ пустой. Очки не стримят: http(s)-URL запрещены. */
val videoUrl: String,
val audioIndex: Int = 0,
val startPositionMs: Long = 0,
) : HostToGlasses
/** Файл контента для скачивания на очки. */
@Serializable
data class FileSpec(
val url: String,
val name: String,
val sizeBytes: Long = 0,
)
/** Скачать контент в память очков: каталог filesDir/viewmate/{itemId}/, файлы — по [FileSpec.name]. */
@Serializable
@SerialName("download_files")
data class DownloadFiles(
val itemId: String,
val title: String,
val files: List<FileSpec>,
) : HostToGlasses
/** Удалить каталог контента на очках. */
@Serializable
@SerialName("delete_files")
data class DeleteFiles(val itemId: String) : HostToGlasses
/** Состояние ассистента (IDLE/LISTENING/THINKING/SPEAKING) + время записи для панели. */
@Serializable
@SerialName("assistant_state")
@@ -22,6 +22,39 @@ data class GlassesStatus(
val batteryPercent: Int,
val storageUsedGb: Double,
val storageTotalGb: Double,
/** Полный объём встроенной памяти, байты. */
val storageTotalBytes: Long = 0,
/** Свободно байт. */
val storageFreeBytes: Long = 0,
/** Сумма скачанного медиа (filesDir/viewmate), байты. */
val mediaBytes: Long = 0,
/** itemId полностью скачанных на очки контентов. */
val downloadedItemIds: List<String> = emptyList(),
/** Каталог контента на очках (filesDir/viewmate) — для локального пути file:// в PlayVideo. */
val mediaDir: String = "",
/** Версия приложения очков (для экрана информации). */
val appVersion: String = "",
) : GlassesToHost
/** Фаза скачивания на очки. */
@Serializable
enum class DownloadPhase {
DOWNLOADING,
DONE,
FAILED,
}
/** Прогресс скачивания одного файла на очки (очки → телефон). */
@Serializable
@SerialName("download_progress")
data class DownloadProgress(
val itemId: String,
/** Имя файла ("video.mkv", "audio-0.ogg") — по нему телефон размечает строки файлов. */
val fileName: String,
val percent: Int,
val doneBytes: Long,
val totalBytes: Long,
val phase: DownloadPhase,
) : GlassesToHost
/** Позиция воспроизведения (очки — мастер таймкода, шлют регулярно). */
@@ -19,6 +19,8 @@ import pw.binom.viewmate.core.media.CreateMirrorRequest
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient
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.HostToGlasses
import pw.binom.viewmate.core.protocol.PlayVideo
import kotlin.test.Test
@@ -156,9 +158,10 @@ class PhoneActionsTest {
}
@Test
fun watchSendsPlayVideoWithUrlAndAudioIndex() = runTest {
fun watchSendsPlayVideoWithLocalPathAndAudioIndex() = runTest {
val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), sender).watch("m1", audioIndex = 1)
val out = actions(simpleJellyfin(), doneMirror(), sender)
.watch("m1", audioIndex = 1, localVideoPath = "file:///data/media/viewmate/m1/video.mkv")
assertTrue(out.contains("включил «Интерстеллар»"))
assertEquals(1, sender.sent.size)
@@ -166,7 +169,7 @@ class PhoneActionsTest {
assertIs<PlayVideo>(msg)
assertEquals("m1", msg.itemId)
assertEquals("Интерстеллар", msg.title)
assertEquals("https://s3/media/m1/video.mp4", msg.videoUrl)
assertEquals("file:///data/media/viewmate/m1/video.mkv", msg.videoUrl)
assertEquals(1, msg.audioIndex)
assertEquals("m1", sender.phoneState.currentItem?.Id)
@@ -175,13 +178,22 @@ class PhoneActionsTest {
assertEquals("done", sender.phoneState.mirrorStatus?.status)
}
@Test
fun watchWithoutLocalPathIsRejected() = runTest {
val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), sender).watch("m1")
assertTrue(out.contains("не скачан на очки"))
assertTrue(sender.sent.isEmpty())
}
@Test
fun watchWhenMirrorNotReadyReturnsHint() = runTest {
val sender = SenderStub()
val mirrorClient = mirror { request ->
respond("", HttpStatusCode.NotFound)
}
val out = actions(simpleJellyfin(), mirrorClient, sender).watch("m1")
val out = actions(simpleJellyfin(), mirrorClient, sender).watch("m1", localVideoPath = "file:///x/m1/video.mkv")
assertTrue(out.contains("не готово"))
assertTrue(sender.sent.isEmpty())
@@ -272,7 +284,35 @@ class PhoneActionsTest {
}
@Test
fun downloadAndDeleteDownloadedEmulate() {
fun downloadToGlassesBuildsAndSendsDownloadFiles() = runTest {
val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), sender)
.downloadToGlasses("m1", sizes = mapOf("video" to 100L)) { "signed-$it" }
assertTrue(out.contains("загрузка"))
assertEquals(1, sender.sent.size)
val msg = sender.sent.single()
assertIs<DownloadFiles>(msg)
assertEquals("m1", msg.itemId)
assertEquals("Интерстеллар", msg.title)
assertEquals(listOf("video.mkv", "audio-1.ogg", "audio-2.ogg"), msg.files.map { it.name })
assertEquals("signed-https://s3/media/m1/video.mp4", msg.files[0].url)
assertEquals(100L, msg.files[0].sizeBytes)
assertTrue(msg.files[0].name.endsWith(".mkv"))
}
@Test
fun downloadToGlassesWhenMirrorNotReadyReturnsHint() = runTest {
val sender = SenderStub()
val mirrorClient = mirror { request -> respond("", HttpStatusCode.NotFound) }
val out = actions(simpleJellyfin(), mirrorClient, sender).downloadToGlasses("m1")
assertTrue(out.contains("не готово"))
assertTrue(sender.sent.isEmpty())
}
@Test
fun downloadAndDeleteDownloadedEmulate() = runTest {
val sender = SenderStub()
val actions = actions(simpleJellyfin(), doneMirror(), sender)
@@ -288,6 +328,17 @@ class PhoneActionsTest {
assertTrue(rm2.contains("не было скачано"))
}
@Test
fun deleteDownloadedSendsDeleteFilesToGlasses() = runTest {
val sender = SenderStub()
actions(simpleJellyfin(), doneMirror(), sender).deleteDownloaded("m1")
assertEquals(1, sender.sent.size)
val msg = sender.sent.single()
assertIs<DeleteFiles>(msg)
assertEquals("m1", msg.itemId)
}
@Test
fun statusShowsCurrentState() = runTest {
val sender = SenderStub()
@@ -298,7 +349,7 @@ class PhoneActionsTest {
assertTrue(empty.contains("ничего"))
assertTrue(empty.contains("пауза"))
actions.watch("m1")
actions.watch("m1", localVideoPath = "file:///x/m1/video.mkv")
val after = actions.status()
assertTrue(after.contains("Играет: Интерстеллар (m1)"))
assertTrue(after.contains("играет"))
@@ -37,6 +37,77 @@ class GlassesToHostTest {
assertEquals(32.0, decoded.storageTotalGb)
}
@Test
fun glassesStatusExtendedFieldsRoundTrip() {
val msg: GlassesToHost = GlassesStatus(
batteryPercent = 60,
storageUsedGb = 12.5,
storageTotalGb = 32.0,
storageTotalBytes = 34_359_738_368,
storageFreeBytes = 20_971_520_000,
mediaBytes = 1_863_946_240,
downloadedItemIds = listOf("i1", "i2"),
mediaDir = "/data/user/0/pw.binom.viewmate.glasses/files/viewmate",
appVersion = "0.2.0",
)
val text = json.encodeToString(GlassesToHost.serializer(), msg)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<GlassesStatus>(decoded)
assertEquals(60, decoded.batteryPercent)
assertEquals(34_359_738_368, decoded.storageTotalBytes)
assertEquals(20_971_520_000, decoded.storageFreeBytes)
assertEquals(1_863_946_240, decoded.mediaBytes)
assertEquals(listOf("i1", "i2"), decoded.downloadedItemIds)
assertEquals("/data/user/0/pw.binom.viewmate.glasses/files/viewmate", decoded.mediaDir)
assertEquals("0.2.0", decoded.appVersion)
}
@Test
fun glassesStatusDefaultsForOldFields() {
val msg: GlassesToHost = GlassesStatus(batteryPercent = 70, storageUsedGb = 10.5, storageTotalGb = 32.0)
val decoded = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), msg))
assertIs<GlassesStatus>(decoded)
assertEquals(0, decoded.storageTotalBytes)
assertEquals(0, decoded.storageFreeBytes)
assertEquals(0, decoded.mediaBytes)
assertTrue(decoded.downloadedItemIds.isEmpty())
assertEquals("", decoded.mediaDir)
assertEquals("", decoded.appVersion)
}
@Test
fun downloadProgressRoundTrip() {
val msg: GlassesToHost = DownloadProgress(
itemId = "movie-1",
fileName = "video.mkv",
percent = 45,
doneBytes = 838_860_800,
totalBytes = 1_863_946_240,
phase = DownloadPhase.DOWNLOADING,
)
val text = json.encodeToString(GlassesToHost.serializer(), msg)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<DownloadProgress>(decoded)
assertEquals("movie-1", decoded.itemId)
assertEquals("video.mkv", decoded.fileName)
assertEquals(45, decoded.percent)
assertEquals(838_860_800, decoded.doneBytes)
assertEquals(1_863_946_240, decoded.totalBytes)
assertEquals(DownloadPhase.DOWNLOADING, decoded.phase)
}
@Test
fun downloadProgressDoneAndFailedPhases() {
val done: GlassesToHost = DownloadProgress("i", "video.mkv", 100, 10, 10, DownloadPhase.DONE)
val failed: GlassesToHost = DownloadProgress("i", "audio-0.ogg", 0, 5, 10, DownloadPhase.FAILED)
val decodedDone = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), done))
val decodedFailed = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), failed))
assertIs<DownloadProgress>(decodedDone)
assertEquals(DownloadPhase.DONE, decodedDone.phase)
assertIs<DownloadProgress>(decodedFailed)
assertEquals(DownloadPhase.FAILED, decodedFailed.phase)
}
@Test
fun playbackPositionRoundTrip() {
val msg: GlassesToHost = PlaybackPosition(positionMs = 65_432, playing = true)
@@ -9,6 +9,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class HostToGlassesTest {
@@ -113,6 +114,57 @@ class HostToGlassesTest {
assertEquals("Планы на выходные", decoded.title)
}
@Test
fun downloadFilesRoundTrip() {
val msg: HostToGlasses = DownloadFiles(
itemId = "movie-1",
title = "Interstellar",
files = listOf(
FileSpec(url = "https://s3/mirror/video.mkv?sig", name = "video.mkv", sizeBytes = 1_863_946_240),
FileSpec(url = "https://s3/mirror/audio-0.ogg?sig", name = "audio-0.ogg", sizeBytes = 68_157_440),
),
)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<DownloadFiles>(decoded)
assertEquals("movie-1", decoded.itemId)
assertEquals("Interstellar", decoded.title)
assertEquals(2, decoded.files.size)
assertEquals("video.mkv", decoded.files[0].name)
assertEquals(1_863_946_240, decoded.files[0].sizeBytes)
assertTrue(decoded.files[0].url.startsWith("https://s3/mirror/video.mkv"))
assertEquals("audio-0.ogg", decoded.files[1].name)
}
@Test
fun downloadFilesDefaultSize() {
val msg: HostToGlasses = DownloadFiles(
itemId = "i",
title = "t",
files = listOf(FileSpec(url = "https://s3/f", name = "video.mkv")),
)
val decoded = json.decodeFromString(HostToGlasses.serializer(), json.encodeToString(HostToGlasses.serializer(), msg))
assertIs<DownloadFiles>(decoded)
assertEquals(0, decoded.files[0].sizeBytes)
}
@Test
fun deleteFilesRoundTrip() {
val msg: HostToGlasses = DeleteFiles(itemId = "movie-1")
val text = json.encodeToString(HostToGlasses.serializer(), msg)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<DeleteFiles>(decoded)
assertEquals("movie-1", decoded.itemId)
}
@Test
fun playVideoEmptyVideoUrlAllowed() {
val msg: HostToGlasses = PlayVideo(itemId = "i", title = "t", videoUrl = "")
val decoded = json.decodeFromString(HostToGlasses.serializer(), json.encodeToString(HostToGlasses.serializer(), msg))
assertIs<PlayVideo>(decoded)
assertEquals("", decoded.videoUrl)
}
@Test
fun discriminatorIsType() {
val text = json.encodeToString(HostToGlasses.serializer(), SetMode(GlassesMode.CHAT))