diff --git a/TESTING.md b/TESTING.md index 8bd2cad..9ea41bf 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 ТЕЛЕФОНА diff --git a/app-glasses/build.gradle.kts b/app-glasses/build.gradle.kts index 9433d71..66fc642 100644 --- a/app-glasses/build.gradle.kts +++ b/app-glasses/build.gradle.kts @@ -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")) } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index 4a5b583..144bb31 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -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}") } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManager.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManager.kt new file mode 100644 index 0000000..2452ac6 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManager.kt @@ -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): 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, totalBytes: Map): 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> = emptyMap(), + val downloaded: List = 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(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() + private val expectedFiles = ConcurrentHashMap>() + private val stateStore = GlassesStateStore(File(baseDir, "status.json")) + + private val _downloadedIds = MutableStateFlow>(emptySet()) + /** itemId, у которых контент полностью скачан на очки (переживает рестарт). */ + val downloadedIds: StateFlow> = _downloadedIds.asStateFlow() + + private val _mediaBytes = MutableStateFlow(0L) + /** Сумма байт полностью скачанных файлов (для GlassesStatus.mediaBytes). */ + val mediaBytes: StateFlow = _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() + val totals = HashMap() + 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 + } + } +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index 7889e1f..c44e062 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -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 = 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" + } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt index 59af7f6..54a2d9e 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt @@ -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 создан") diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManagerTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManagerTest.kt new file mode 100644 index 0000000..35e5c75 --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManagerTest.kt @@ -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"))) + } +} diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt index e78b8aa..f1a2824 100644 --- a/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt @@ -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 -> { diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt index 3c7b6c6..43e5e9f 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt @@ -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 /** * Аудио-плеер телефона, синхронизированный с мастер-таймкодом очков. diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt index dd6939b..17fe3a9 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt @@ -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 = emptyList(), + /** Размер каждой дорожки (key → байт), заполняется после получения заголовков. */ + val fileSizes: Map = emptyMap(), ) internal val statusJson: Json = Json { @@ -82,9 +87,96 @@ internal data class DiskScanResult( ) /** - * Скан папок filesDir/viewmate/: финальный video.mkv → itemId в downloaded - * (заодно чистим лишние .part); есть только .part → фаза downloading с percent - * из размера .part и totalBytes из статуса. + * Удалить состояние скачивания [itemId] из всех карт. Идемпотентно: + * повторный вызов ничего не меняет (карты уже не содержат itemId). + */ +internal fun removeDownloadState( + statuses: MutableMap, + downloads: MutableMap, + downloadedIds: MutableSet, + itemId: String, +) { + downloads.remove(itemId) + downloadedIds.remove(itemId) + statuses.remove(itemId) +} + +/** + * Актуальна ли качка поколения [generation] для [itemId]. [delete] увеличивает поколение — + * старая незавершённая корутина после удаления видит несовпадение и не «воскрешает» качку. + * Нет записи — поколение 0 (никогда не удалялось). + */ +internal fun isCurrentGeneration(generations: Map, 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 = + listOf("video") + files.audios.map { "audio-${it.index}" } + +/** Полностью ли скачано на диск: все финальные файлы [keys] есть, >0 байт, без .part. */ +internal fun isFullyOnDisk(dir: File, keys: List): 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, sizes: Map): 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): 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, done: Map): 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 в 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() + + /** + * Поколение запуска качки itemId. Растёт при [delete]: незавершённая корутина + * после удаления видит несовпадение поколений и «умирает», не создавая файлы/статус заново. + */ + private val generations = ConcurrentHashMap() + private val statuses = ConcurrentHashMap() 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 = + 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() + val done = HashMap() + 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) { + 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) } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesFiles.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesFiles.kt new file mode 100644 index 0000000..b58b82d --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesFiles.kt @@ -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 { + 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 { + 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, + knownSizes: Map, + perFile: Map, +): 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): 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) +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index 63d1ba0..43fcca0 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -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(null) val storageTotalGb: StateFlow = _storageTotalGb.asStateFlow() + private val _storageTotalBytes = MutableStateFlow(null) + val storageTotalBytes: StateFlow = _storageTotalBytes.asStateFlow() + + private val _storageFreeBytes = MutableStateFlow(null) + val storageFreeBytes: StateFlow = _storageFreeBytes.asStateFlow() + + private val _mediaBytes = MutableStateFlow(null) + val mediaBytes: StateFlow = _mediaBytes.asStateFlow() + + private val _appVersion = MutableStateFlow(null) + val appVersion: StateFlow = _appVersion.asStateFlow() + + private val _mediaDir = MutableStateFlow(null) + val mediaDir: StateFlow = _mediaDir.asStateFlow() + + private val _downloadedItemIds = MutableStateFlow>(emptySet()) + /** itemId полностью скачанных на очки (из периодического GlassesStatus). */ + val downloadedItemIds: StateFlow> = _downloadedItemIds.asStateFlow() + + private val _glassesDownloads = MutableStateFlow>>(emptyMap()) + /** Прогресс скачивания на очки: itemId → имя файла → последний DownloadProgress от очков. */ + val glassesDownloads: StateFlow>> = _glassesDownloads.asStateFlow() + private val _positionMs = MutableStateFlow(null) val positionMs: StateFlow = _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 -> { diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt index 64e5d22..fdab495 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt @@ -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) { diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt index 55c72b3..c6f5913 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt @@ -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> = 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, + glasses: Pair, +): 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, + glasses: Pair, +) { + 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) { + 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) } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesInfoScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesInfoScreen.kt new file mode 100644 index 0000000..62b3b85 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesInfoScreen.kt @@ -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, + ) + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt index c6fdf1a..4a65a11 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt @@ -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>(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) } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt index 4d266a3..77f7fb9 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt @@ -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() } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt index caa8079..9af2078 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt @@ -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 = 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}") + } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt index 25679f4..01861b4 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt @@ -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 не затронуты") + } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesFilesTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesFilesTest.kt new file mode 100644 index 0000000..bedba8e --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesFilesTest.kt @@ -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)) + } +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt index 55dd20e..0083b7f 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt @@ -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()) { diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/ui/ComponentsTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/ui/ComponentsTest.kt new file mode 100644 index 0000000..fd6dbcb --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/ui/ComponentsTest.kt @@ -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)) + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesDownloadBuild.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesDownloadBuild.kt new file mode 100644 index 0000000..3cc0159 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/GlassesDownloadBuild.kt @@ -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 = + listOf("video") + files.audios.map { "audio-${it.index}" } + +/** Собрать список файлов для DownloadFiles на очки: presigned URL, имя файла, размер (если известен). */ +fun buildGlassesFileSpecs( + files: MirrorFiles, + sizes: Map, + presign: (String) -> String, +): List { + 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, + presign: (String) -> String, +): DownloadFiles = DownloadFiles( + itemId = itemId, + title = title, + files = buildGlassesFileSpecs(files, sizes, presign), +) diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt index 9dcc0b2..1d1d817 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt @@ -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 = 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" } // --- сессии --- diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt index 82e3117..936b297 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt @@ -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, +) : HostToGlasses + +/** Удалить каталог контента на очках. */ +@Serializable +@SerialName("delete_files") +data class DeleteFiles(val itemId: String) : HostToGlasses + /** Состояние ассистента (IDLE/LISTENING/THINKING/SPEAKING) + время записи для панели. */ @Serializable @SerialName("assistant_state") diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt index 78391ba..965d052 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt @@ -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 = 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 /** Позиция воспроизведения (очки — мастер таймкода, шлют регулярно). */ diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt index 7e44e27..a16ab38 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/phone/PhoneActionsTest.kt @@ -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(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(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(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("играет")) diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt index 1b0a8bb..0d87195 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt @@ -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(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(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(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(decodedDone) + assertEquals(DownloadPhase.DONE, decodedDone.phase) + assertIs(decodedFailed) + assertEquals(DownloadPhase.FAILED, decodedFailed.phase) + } + @Test fun playbackPositionRoundTrip() { val msg: GlassesToHost = PlaybackPosition(positionMs = 65_432, playing = true) diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt index 5edfb70..d1206bc 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt @@ -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(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(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(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(decoded) + assertEquals("", decoded.videoUrl) + } + @Test fun discriminatorIsType() { val text = json.encodeToString(HostToGlasses.serializer(), SetMode(GlassesMode.CHAT))