fix(app-phone): звук при watch (диагностика+подстраховка mirrorByItem) + качка не воскресает после удаления
- onWatch: явные логи в каждой точке отказа (watch result / audio source / уже играет / не скачано / mediaDir пуст), свежий mirrorByItem если files не догрузился, звук стартует с позиции очков при уже идущем видео - resolveAudioSource → resolveAudioSourceDetailed (AudioSourceResult с причиной null) - DownloadManager: поколения generations — delete() инвалидирует старую корутину (не воскрешает качку), ensureActive перед .part/rename, jobs.remove(itemId, job) - Тесты 62/0 (в т.ч. поколения — поймали null != 0L) Инфраструктурный блокер (не код): SeaweedFS на Server3 76.66 в IO-шторме (load ~184, wa 20%, чужой yt-dlp на хосте) — большие GET с s3.binom.pw висят/рвутся с ~14:00; качка на очки FAILED по этой причине.
This commit is contained in:
+30
@@ -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 ТЕЛЕФОНА
|
||||
|
||||
@@ -50,8 +50,11 @@ dependencies {
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(project(":lib-core"))
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// WS-клиент очков (OkHttp — Android-движок)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
@@ -20,11 +20,21 @@ class GlassesApp : Application() {
|
||||
lateinit var movieController: MovieController
|
||||
private set
|
||||
|
||||
/** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */
|
||||
lateinit var downloadManager: GlassesDownloadManager
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
movieController = MovieController(applicationContext)
|
||||
hostConnection = HostConnection(GlassesConfig.HOST_URL, movieController)
|
||||
downloadManager = GlassesDownloadManager(applicationContext) { msg -> hostConnection.sendToHost(msg) }
|
||||
hostConnection = HostConnection(
|
||||
url = GlassesConfig.HOST_URL,
|
||||
movieController = movieController,
|
||||
downloadManager = downloadManager,
|
||||
context = applicationContext,
|
||||
)
|
||||
hostConnection.start()
|
||||
log("app", "HostConnection стартует: ${GlassesConfig.HOST_URL}")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import pw.binom.viewmate.core.protocol.DownloadFiles
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||
import pw.binom.viewmate.core.protocol.FileSpec
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// --- чистая логика (тестируется без Android) ---
|
||||
|
||||
/** Имя .part-файла ("video.mkv" → "video.mkv.part"). */
|
||||
internal fun partName(fileName: String): String = "$fileName.part"
|
||||
|
||||
/** Полностью ли скачано на диск: все [fileNames] есть, >0 байт, без .part. */
|
||||
internal fun isFullyOnDisk(dir: File, fileNames: List<String>): Boolean {
|
||||
if (fileNames.isEmpty()) return false
|
||||
return fileNames.all { name ->
|
||||
val final = File(dir, name)
|
||||
final.isFile && final.length() > 0 && !File(dir, partName(name)).exists()
|
||||
}
|
||||
}
|
||||
|
||||
/** OkHttp-запрос на скачивание: при [existingBytes] > 0 докачиваем с Range. */
|
||||
internal fun glassesRequestFor(url: String, existingBytes: Long): Request {
|
||||
val builder = Request.Builder().url(url)
|
||||
if (existingBytes > 0) builder.header("Range", "bytes=$existingBytes-")
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/** Процент одного файла (done/total). */
|
||||
internal fun filePercent(doneBytes: Long, totalBytes: Long): Int {
|
||||
if (totalBytes <= 0) return 0
|
||||
return ((doneBytes * 100) / totalBytes).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
/** Общий процент по всем файлам (суммы байтов). */
|
||||
internal fun overallPercent(doneBytes: Map<String, Long>, totalBytes: Map<String, Long>): Int {
|
||||
val total = totalBytes.values.sum()
|
||||
if (total <= 0) return 0
|
||||
val done = doneBytes.values.sum()
|
||||
return ((done * 100) / total).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
// --- персистентность ---
|
||||
|
||||
@Serializable
|
||||
internal data class GlassesState(
|
||||
val expected: Map<String, List<String>> = emptyMap(),
|
||||
val downloaded: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
internal val glassesStateJson: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
/** Запись/чтение status.json очков: атомарно через временный файл. */
|
||||
internal class GlassesStateStore(private val file: File) {
|
||||
fun read(): GlassesState = runCatching {
|
||||
if (!file.exists()) return@runCatching GlassesState()
|
||||
file.inputStream().use { stream ->
|
||||
glassesStateJson.decodeFromString<GlassesState>(stream.readBytes().decodeToString())
|
||||
}
|
||||
}.getOrDefault(GlassesState())
|
||||
|
||||
fun write(state: GlassesState) {
|
||||
try {
|
||||
file.parentFile?.mkdirs()
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
tmp.writeText(glassesStateJson.encodeToString(state))
|
||||
if (!tmp.renameTo(file)) file.writeText(glassesStateJson.encodeToString(state))
|
||||
} catch (e: Exception) {
|
||||
log("glasses", "не удалось сохранить состояние: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Менеджер скачивания контента в память очков (аналог телефонного DownloadManager).
|
||||
* Каталог: filesDir/viewmate/{itemId}/video.mkv + audio-N.ogg. Скачивание пишется в .part
|
||||
* и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send].
|
||||
*/
|
||||
class GlassesDownloadManager(
|
||||
context: Context,
|
||||
private val send: suspend (GlassesToHost) -> Unit,
|
||||
) {
|
||||
val baseDir = File(context.filesDir, "viewmate")
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val jobs = ConcurrentHashMap<String, Job>()
|
||||
private val expectedFiles = ConcurrentHashMap<String, List<String>>()
|
||||
private val stateStore = GlassesStateStore(File(baseDir, "status.json"))
|
||||
|
||||
private val _downloadedIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
/** itemId, у которых контент полностью скачан на очки (переживает рестарт). */
|
||||
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
|
||||
|
||||
private val _mediaBytes = MutableStateFlow(0L)
|
||||
/** Сумма байт полностью скачанных файлов (для GlassesStatus.mediaBytes). */
|
||||
val mediaBytes: StateFlow<Long> = _mediaBytes.asStateFlow()
|
||||
|
||||
private val okHttp = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // длинные файлы, паузы в потоке
|
||||
.build()
|
||||
|
||||
init {
|
||||
loadPersisted()
|
||||
mediaBytesNow()
|
||||
}
|
||||
|
||||
private fun loadPersisted() {
|
||||
val state = stateStore.read()
|
||||
expectedFiles.putAll(state.expected)
|
||||
_downloadedIds.value = state.downloaded.toSet()
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
stateStore.write(
|
||||
GlassesState(
|
||||
expected = expectedFiles.entries.associate { (k, v) -> k to v.toList() },
|
||||
downloaded = _downloadedIds.value.toList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun dirFor(itemId: String): File = File(baseDir, itemId)
|
||||
|
||||
/** Полностью ли скачано зеркало на очки: все ожидаемые файлы есть без .part (список — из DownloadFiles). */
|
||||
fun isFullyDownloaded(itemId: String): Boolean {
|
||||
val names = expectedFiles[itemId] ?: return false
|
||||
return isFullyOnDisk(dirFor(itemId), names)
|
||||
}
|
||||
|
||||
/** Локальный путь к видео для PlayVideo (file://...), null если не скачано. */
|
||||
fun localVideoPath(itemId: String): String? {
|
||||
if (!isFullyDownloaded(itemId)) return null
|
||||
return "file://${File(dirFor(itemId), "video.mkv").absolutePath}"
|
||||
}
|
||||
|
||||
/** Пересчитать mediaBytes с диска и вернуть значение. */
|
||||
fun mediaBytesNow(): Long {
|
||||
var total = 0L
|
||||
baseDir.listFiles()?.forEach { dir ->
|
||||
if (dir.isDirectory) {
|
||||
dir.listFiles { f -> f.isFile && !f.name.endsWith(".part") }?.forEach { total += it.length() }
|
||||
}
|
||||
}
|
||||
_mediaBytes.value = total
|
||||
return total
|
||||
}
|
||||
|
||||
/** Обработка DownloadFiles: скачиваем все файлы последовательно со сквозным прогрессом на телефон. */
|
||||
fun handleDownloadFiles(msg: DownloadFiles) {
|
||||
if (jobs[msg.itemId]?.isActive == true) return
|
||||
val names = msg.files.map { it.name }
|
||||
expectedFiles[msg.itemId] = names
|
||||
persist()
|
||||
|
||||
jobs[msg.itemId] = scope.launch {
|
||||
val done = HashMap<String, Long>()
|
||||
val totals = HashMap<String, Long>()
|
||||
try {
|
||||
val dir = dirFor(msg.itemId)
|
||||
dir.mkdirs()
|
||||
|
||||
if (isFullyOnDisk(dir, names)) {
|
||||
markDownloaded(msg.itemId, true)
|
||||
log("glasses", "${msg.itemId} уже полностью скачан на очки")
|
||||
return@launch
|
||||
}
|
||||
|
||||
msg.files.forEach { spec ->
|
||||
var lastSentAt = 0L
|
||||
val total = downloadFile(
|
||||
spec = spec,
|
||||
part = File(dir, partName(spec.name)),
|
||||
dest = File(dir, spec.name),
|
||||
onTotal = { totals[spec.name] = it },
|
||||
onProgress = { d, t ->
|
||||
done[spec.name] = d
|
||||
totals[spec.name] = t
|
||||
val now = System.currentTimeMillis()
|
||||
// прогресс шлём не чаще раза в полсекунды
|
||||
if (now - lastSentAt >= 500) {
|
||||
lastSentAt = now
|
||||
send(DownloadProgress(msg.itemId, spec.name, filePercent(d, t), d, t, DownloadPhase.DOWNLOADING))
|
||||
}
|
||||
},
|
||||
)
|
||||
done[spec.name] = total
|
||||
totals[spec.name] = total
|
||||
send(DownloadProgress(msg.itemId, spec.name, 100, total, total, DownloadPhase.DONE))
|
||||
}
|
||||
|
||||
markDownloaded(msg.itemId, true)
|
||||
mediaBytesNow()
|
||||
log("glasses", "скачано на очки ${msg.itemId} → $dir")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("glasses", "ошибка скачивания ${msg.itemId}: ${e.message}")
|
||||
val last = done.entries.lastOrNull()
|
||||
send(
|
||||
DownloadProgress(
|
||||
itemId = msg.itemId,
|
||||
fileName = last?.key ?: names.firstOrNull().orEmpty(),
|
||||
percent = last?.let { filePercent(it.value, totals[it.key] ?: 0L) } ?: 0,
|
||||
doneBytes = last?.value ?: 0L,
|
||||
totalBytes = last?.let { totals[it.key] ?: 0L } ?: 0L,
|
||||
phase = DownloadPhase.FAILED,
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
jobs.remove(msg.itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Обработка DeleteFiles: удалить каталог контента на очках. */
|
||||
fun handleDeleteFiles(itemId: String) {
|
||||
jobs.remove(itemId)?.cancel()
|
||||
dirFor(itemId).deleteRecursively()
|
||||
expectedFiles.remove(itemId)
|
||||
markDownloaded(itemId, false)
|
||||
mediaBytesNow()
|
||||
log("glasses", "удалён каталог $itemId с очков")
|
||||
}
|
||||
|
||||
private fun markDownloaded(itemId: String, downloaded: Boolean) {
|
||||
_downloadedIds.value = if (downloaded) _downloadedIds.value + itemId else _downloadedIds.value - itemId
|
||||
persist()
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
|
||||
* по завершении — rename в [dest] (без .part). Возвращает полный размер.
|
||||
*/
|
||||
private suspend fun downloadFile(
|
||||
spec: FileSpec,
|
||||
part: File,
|
||||
dest: File,
|
||||
onProgress: suspend (done: Long, total: Long) -> Unit,
|
||||
onTotal: suspend (Long) -> Unit,
|
||||
): Long {
|
||||
val existing = if (part.exists()) part.length() else 0L
|
||||
okHttp.newCall(glassesRequestFor(spec.url, existing)).execute().use { response ->
|
||||
val code = response.code
|
||||
if (code != 200 && code != 206) error("HTTP $code для ${spec.name}")
|
||||
val body = response.body ?: error("пустое тело ответа для ${spec.name}")
|
||||
|
||||
// 206 — сервер уважил Range; если вернул 200, начинаем заново с нуля
|
||||
val resumed = code == 206 && existing > 0
|
||||
val from = if (resumed) existing else 0L
|
||||
val total = if (spec.sizeBytes > 0) {
|
||||
spec.sizeBytes
|
||||
} else if (resumed) {
|
||||
response.header("Content-Range")?.substringAfterLast('/')?.toLongOrNull()
|
||||
?: (from + body.contentLength())
|
||||
} else {
|
||||
body.contentLength()
|
||||
}
|
||||
onTotal(total)
|
||||
|
||||
val input = body.byteStream()
|
||||
FileOutputStream(part, resumed).use { out ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var done = from
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
if (total > 0) onProgress(done, total)
|
||||
}
|
||||
}
|
||||
|
||||
if (dest.exists()) dest.delete()
|
||||
if (!part.renameTo(dest)) {
|
||||
part.copyTo(dest, overwrite = true)
|
||||
part.delete()
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import android.content.Context
|
||||
import android.os.BatteryManager
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -14,7 +18,10 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import pw.binom.viewmate.core.net.GlassesWsClient
|
||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
import pw.binom.viewmate.core.protocol.DeleteFiles
|
||||
import pw.binom.viewmate.core.protocol.DownloadFiles
|
||||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import pw.binom.viewmate.core.protocol.Hello
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
@@ -27,12 +34,16 @@ import pw.binom.viewmate.core.protocol.Welcome
|
||||
/**
|
||||
* Обвязка над [GlassesWsClient] для UI очков:
|
||||
* реконнект, Hello при подключении, периодический статус/позиция,
|
||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером).
|
||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
|
||||
* DownloadFiles/DeleteFiles — менеджером скачивания).
|
||||
*/
|
||||
class HostConnection(
|
||||
private val url: String,
|
||||
private val movieController: MovieController,
|
||||
private val downloadManager: GlassesDownloadManager,
|
||||
context: Context,
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
private val client = GlassesWsClient(url)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@@ -53,7 +64,7 @@ class HostConnection(
|
||||
_connected.value = true
|
||||
_status.value = "подключено: $url"
|
||||
addMessage("соединение установлено")
|
||||
client.send(Hello(appVersion = "0.1.0"))
|
||||
client.send(Hello(appVersion = APP_VERSION))
|
||||
},
|
||||
onDisconnected = {
|
||||
_connected.value = false
|
||||
@@ -64,14 +75,44 @@ class HostConnection(
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(5_000)
|
||||
// статус/позиция — заглушки (реальные батарея/память — позже)
|
||||
client.send(GlassesStatus(batteryPercent = 80, storageUsedGb = 10.5, storageTotalGb = 32.0))
|
||||
client.send(buildStatus())
|
||||
val (positionMs, playing) = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||
client.send(PlaybackPosition(positionMs = positionMs, playing = playing))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Отправка сообщения на телефон (используется менеджером скачивания). */
|
||||
suspend fun sendToHost(msg: GlassesToHost) = client.send(msg)
|
||||
|
||||
private fun buildStatus(): GlassesStatus {
|
||||
val (totalBytes, freeBytes) = diskBytes()
|
||||
val usedBytes = (totalBytes - freeBytes).coerceAtLeast(0L)
|
||||
val media = downloadManager.mediaBytesNow()
|
||||
return GlassesStatus(
|
||||
batteryPercent = batteryPercent(),
|
||||
storageUsedGb = usedBytes / 1_000_000_000.0,
|
||||
storageTotalGb = totalBytes / 1_000_000_000.0,
|
||||
storageTotalBytes = totalBytes,
|
||||
storageFreeBytes = freeBytes,
|
||||
mediaBytes = media,
|
||||
downloadedItemIds = downloadManager.downloadedIds.value.toList(),
|
||||
mediaDir = downloadManager.baseDir.absolutePath,
|
||||
appVersion = APP_VERSION,
|
||||
)
|
||||
}
|
||||
|
||||
private fun batteryPercent(): Int {
|
||||
val bm = appContext.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
|
||||
val level = bm?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||
return if (level != null && level in 0..100) level else 80
|
||||
}
|
||||
|
||||
private fun diskBytes(): Pair<Long, Long> = runCatching {
|
||||
val stat = StatFs(Environment.getDataDirectory().absolutePath)
|
||||
stat.totalBytes to stat.availableBytes
|
||||
}.getOrDefault(0L to 0L)
|
||||
|
||||
private suspend fun handleHostMessage(msg: HostToGlasses) {
|
||||
when (msg) {
|
||||
is Welcome -> {
|
||||
@@ -91,6 +132,16 @@ class HostConnection(
|
||||
movieController.applyMediaCommand(msg.command, msg.seekMs)
|
||||
}
|
||||
|
||||
is DownloadFiles -> {
|
||||
addMessage("скачивание на очки: «${msg.title}» (${msg.files.size} файлов)")
|
||||
downloadManager.handleDownloadFiles(msg)
|
||||
}
|
||||
|
||||
is DeleteFiles -> {
|
||||
addMessage("удаление с очков: ${msg.itemId}")
|
||||
downloadManager.handleDeleteFiles(msg.itemId)
|
||||
}
|
||||
|
||||
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
|
||||
|
||||
is ShowText -> addMessage("ShowText: ${msg.text}")
|
||||
@@ -108,4 +159,8 @@ class HostConnection(
|
||||
client.close()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val APP_VERSION = "0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
@@ -10,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Держатель плеера фильма: создаёт/освобождает SimpleExoPlayer,
|
||||
@@ -30,6 +32,16 @@ class MovieController(private val context: Context) {
|
||||
|
||||
fun playVideo(msg: PlayVideo) {
|
||||
mainHandler.post {
|
||||
// Стриминг отключён полностью: играем ТОЛЬКО локальный файл file://, который есть на диске.
|
||||
if (!msg.videoUrl.startsWith("file://")) {
|
||||
log("glasses", "PlayVideo отклонён: стриминг запрещён, url не локальный (${msg.videoUrl.take(80)})")
|
||||
return@post
|
||||
}
|
||||
val local = Uri.parse(msg.videoUrl).path?.let { File(it) }
|
||||
if (local == null || !local.isFile || local.length() <= 0) {
|
||||
log("glasses", "PlayVideo отклонён: локальный файл не найден (${msg.videoUrl})")
|
||||
return@post
|
||||
}
|
||||
val player = _player ?: SimpleExoPlayer.Builder(context).build().also {
|
||||
_player = it
|
||||
log("glasses", "SimpleExoPlayer создан")
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GlassesDownloadManagerTest {
|
||||
|
||||
private fun tempDir(): File = Files.createTempDirectory("glasses-dm-test").toFile()
|
||||
|
||||
private fun newBaseDir(): File {
|
||||
val base = tempDir()
|
||||
base.deleteOnExit()
|
||||
return base
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isFullyOnDiskRequiresAllFilesNoParts() {
|
||||
val dir = newBaseDir()
|
||||
val names = listOf("video.mkv", "audio-0.ogg", "audio-1.ogg")
|
||||
|
||||
assertFalse(isFullyOnDisk(dir, names), "пустой каталог — не полный")
|
||||
|
||||
File(dir, "video.mkv").writeBytes(ByteArray(10))
|
||||
assertFalse(isFullyOnDisk(dir, names), "только видео — не полный")
|
||||
|
||||
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
|
||||
assertFalse(isFullyOnDisk(dir, names), "не все аудио — не полный")
|
||||
|
||||
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
|
||||
assertTrue(isFullyOnDisk(dir, names), "все файлы — полный")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isFullyOnDiskRejectsPartFiles() {
|
||||
val dir = newBaseDir()
|
||||
File(dir, "video.mkv").writeBytes(ByteArray(10))
|
||||
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
|
||||
File(dir, "audio-0.ogg.part").writeBytes(ByteArray(2))
|
||||
|
||||
assertFalse(isFullyOnDisk(dir, listOf("video.mkv", "audio-0.ogg")), ".part не даёт полного")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isFullyOnDiskEmptyListIsFalse() {
|
||||
assertFalse(isFullyOnDisk(newBaseDir(), emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partNameAppendsSuffix() {
|
||||
assertEquals("video.mkv.part", partName("video.mkv"))
|
||||
assertEquals("audio-0.ogg.part", partName("audio-0.ogg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestForAddsRangeFromExistingPart() {
|
||||
val noResume = glassesRequestFor("https://s3/mirror/video.mkv?sig", 0)
|
||||
assertNull(noResume.header("Range"), "без .part Range-заголовка быть не должно")
|
||||
|
||||
val resume = glassesRequestFor("https://s3/mirror/video.mkv?sig", 1_863_946_240)
|
||||
assertEquals("bytes=1863946240-", resume.header("Range"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filePercentClampsToRange() {
|
||||
assertEquals(0, filePercent(0, 0))
|
||||
assertEquals(50, filePercent(500, 1000))
|
||||
assertEquals(100, filePercent(1000, 1000))
|
||||
assertEquals(99, filePercent(999, 1000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overallPercentSumsAcrossFiles() {
|
||||
val done = mapOf("video.mkv" to 100L, "audio-0.ogg" to 25L, "audio-1.ogg" to 0L)
|
||||
val total = mapOf("video.mkv" to 100L, "audio-0.ogg" to 50L, "audio-1.ogg" to 50L)
|
||||
assertEquals(62, overallPercent(done, total)) // 125/200
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overallPercentZeroWithoutTotals() {
|
||||
assertEquals(0, overallPercent(mapOf("video.mkv" to 5L), emptyMap()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateStoreWritesAndReads() {
|
||||
val file = File(tempDir(), "status.json")
|
||||
val store = GlassesStateStore(file)
|
||||
val state = GlassesState(
|
||||
expected = mapOf("i1" to listOf("video.mkv", "audio-0.ogg")),
|
||||
downloaded = listOf("i1"),
|
||||
)
|
||||
|
||||
store.write(state)
|
||||
val read = store.read()
|
||||
|
||||
assertEquals(state, read)
|
||||
assertTrue(file.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateStoreMissingReadsEmpty() {
|
||||
val store = GlassesStateStore(File(tempDir(), "status.json"))
|
||||
assertEquals(GlassesState(), store.read())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localVideoPathNullWhenNotDownloaded() {
|
||||
val base = newBaseDir()
|
||||
val dir = File(base, "i1").apply { mkdirs() }
|
||||
File(dir, "video.mkv").writeBytes(ByteArray(10))
|
||||
|
||||
// без ожидаемого списка файлов isFullyDownloaded = false
|
||||
assertFalse(isFullyOnDisk(dir, listOf("video.mkv", "audio-0.ogg")))
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import kotlinx.serialization.json.Json
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.phone.GlassesSender
|
||||
import pw.binom.viewmate.core.phone.PhoneState
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||
import pw.binom.viewmate.core.protocol.GlassesOff
|
||||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
@@ -96,7 +97,11 @@ open class GlassesHub(
|
||||
}
|
||||
|
||||
is GlassesStatus -> {
|
||||
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
|
||||
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ, медиа ${msg.mediaBytes} байт")
|
||||
}
|
||||
|
||||
is DownloadProgress -> {
|
||||
log("glasses", "скачивание на очки: ${msg.itemId} ${msg.fileName} ${msg.percent}% (${msg.phase})")
|
||||
}
|
||||
|
||||
is PlaybackPosition -> {
|
||||
|
||||
@@ -50,26 +50,55 @@ internal fun decideSync(
|
||||
return SyncDecision(seekToMs = seekToMs, playing = playing)
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат выбора аудио-источника: [source] (локальный путь или presigned URL)
|
||||
* либо null с человекочитаемой [reason] — чтобы лог показал точную причину отсутствия звука.
|
||||
*/
|
||||
internal data class AudioSourceResult(
|
||||
val source: String?,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Источник аудио для просмотра на очках: локальный скачанный `audio-N.ogg`
|
||||
* (если есть — быстрее, без сети), иначе presigned URL дорожки [audioIndex]
|
||||
* (стриминг, без скачивания). null — дорожки нет.
|
||||
* (стриминг, без скачивания). null — дорожки нет, причина в [AudioSourceResult.reason].
|
||||
* Чистая функция (без Android) — покрыта unit-тестами.
|
||||
*/
|
||||
internal fun resolveAudioSourceDetailed(
|
||||
mirrorFiles: MirrorFiles?,
|
||||
audioIndex: Int,
|
||||
localAudioFile: (index: Int) -> File,
|
||||
presign: (url: String) -> String = { S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION) },
|
||||
): AudioSourceResult {
|
||||
if (mirrorFiles == null) {
|
||||
return AudioSourceResult(null, "files=null (зеркало не получено)")
|
||||
}
|
||||
val audio = mirrorFiles.audios.firstOrNull { it.index == audioIndex } ?: mirrorFiles.audios.firstOrNull()
|
||||
if (audio == null) {
|
||||
return AudioSourceResult(null, "audios пусто (искал дорожку #$audioIndex)")
|
||||
}
|
||||
val local = localAudioFile(audio.index)
|
||||
return if (local.exists()) {
|
||||
AudioSourceResult(local.absolutePath, "локальный файл ${local.absolutePath}")
|
||||
} else {
|
||||
AudioSourceResult(presign(audio.url), "стриминг (локального audio-${audio.index}.ogg на телефоне нет)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public-обёртка для UI: источник аудио по [DownloadManager] (S3-presign по умолчанию).
|
||||
*/
|
||||
fun resolveAudioSource(
|
||||
downloadManager: DownloadManager,
|
||||
itemId: String,
|
||||
mirrorFiles: MirrorFiles?,
|
||||
audioIndex: Int,
|
||||
): String? {
|
||||
val files = mirrorFiles ?: return null
|
||||
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull() ?: return null
|
||||
val local = downloadManager.audioFile(itemId, audio.index)
|
||||
return if (local.exists()) {
|
||||
local.absolutePath
|
||||
} else {
|
||||
S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
|
||||
}
|
||||
}
|
||||
): String? = resolveAudioSourceDetailed(
|
||||
mirrorFiles = mirrorFiles,
|
||||
audioIndex = audioIndex,
|
||||
localAudioFile = { downloadManager.audioFile(itemId, it) },
|
||||
).source
|
||||
|
||||
/**
|
||||
* Аудио-плеер телефона, синхронизированный с мастер-таймкодом очков.
|
||||
|
||||
@@ -19,6 +19,7 @@ import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
import pw.binom.viewmate.core.media.MirrorFiles
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -47,6 +48,10 @@ data class DownloadStatus(
|
||||
val percent: Int = 0,
|
||||
val totalBytes: Long = 0,
|
||||
val updatedAt: Long = 0,
|
||||
/** Ожидаемый набор дорожек: "video", "audio-0", ... — из зеркала, а не из того, что на диске. */
|
||||
val expectedFiles: List<String> = emptyList(),
|
||||
/** Размер каждой дорожки (key → байт), заполняется после получения заголовков. */
|
||||
val fileSizes: Map<String, Long> = emptyMap(),
|
||||
)
|
||||
|
||||
internal val statusJson: Json = Json {
|
||||
@@ -82,9 +87,96 @@ internal data class DiskScanResult(
|
||||
)
|
||||
|
||||
/**
|
||||
* Скан папок filesDir/viewmate/<itemId>: финальный video.mkv → itemId в downloaded
|
||||
* (заодно чистим лишние .part); есть только .part → фаза downloading с percent
|
||||
* из размера .part и totalBytes из статуса.
|
||||
* Удалить состояние скачивания [itemId] из всех карт. Идемпотентно:
|
||||
* повторный вызов ничего не меняет (карты уже не содержат itemId).
|
||||
*/
|
||||
internal fun removeDownloadState(
|
||||
statuses: MutableMap<String, DownloadStatus>,
|
||||
downloads: MutableMap<String, DownloadProgress>,
|
||||
downloadedIds: MutableSet<String>,
|
||||
itemId: String,
|
||||
) {
|
||||
downloads.remove(itemId)
|
||||
downloadedIds.remove(itemId)
|
||||
statuses.remove(itemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Актуальна ли качка поколения [generation] для [itemId]. [delete] увеличивает поколение —
|
||||
* старая незавершённая корутина после удаления видит несовпадение и не «воскрешает» качку.
|
||||
* Нет записи — поколение 0 (никогда не удалялось).
|
||||
*/
|
||||
internal fun isCurrentGeneration(generations: Map<String, Long>, itemId: String, generation: Long): Boolean =
|
||||
(generations[itemId] ?: 0L) == generation
|
||||
|
||||
/** Имя финального файла для ключа дорожки ("video" → "video.mkv", "audio-0" → "audio-0.ogg"). */
|
||||
internal fun finalFileName(key: String): String = if (key == "video") "video.mkv" else "$key.ogg"
|
||||
|
||||
/** Имя .part-файла для ключа дорожки. */
|
||||
internal fun partFileName(key: String): String = "${finalFileName(key)}.part"
|
||||
|
||||
/** Ключ дорожки по имени .part-файла ("video.mkv.part" → "video", "audio-0.ogg.part" → "audio-0"). */
|
||||
internal fun fileKeyFor(partName: String): String {
|
||||
val base = partName.removeSuffix(".part")
|
||||
return if (base == "video.mkv") "video" else base.removeSuffix(".ogg")
|
||||
}
|
||||
|
||||
/** Список ключей дорожек зеркала: video + каждая аудио (из files.audios, а не с диска). */
|
||||
internal fun fileKeys(files: MirrorFiles): List<String> =
|
||||
listOf("video") + files.audios.map { "audio-${it.index}" }
|
||||
|
||||
/** Полностью ли скачано на диск: все финальные файлы [keys] есть, >0 байт, без .part. */
|
||||
internal fun isFullyOnDisk(dir: File, keys: List<String>): Boolean {
|
||||
if (keys.isEmpty()) return false
|
||||
return keys.all { key ->
|
||||
val final = File(dir, finalFileName(key))
|
||||
final.isFile && final.length() > 0 && !File(dir, partFileName(key)).exists()
|
||||
}
|
||||
}
|
||||
|
||||
/** Общий процент скачанного на диск по .part/финальным файлам относительно [sizes]. */
|
||||
internal fun diskPercent(dir: File, keys: List<String>, sizes: Map<String, Long>): Int {
|
||||
if (keys.isEmpty()) return 0
|
||||
val totalSum = keys.sumOf { sizes[it] ?: 0L }
|
||||
if (totalSum <= 0) return 0
|
||||
var done = 0L
|
||||
keys.forEach { key ->
|
||||
val size = sizes[key] ?: 0L
|
||||
val part = File(dir, partFileName(key))
|
||||
val final = File(dir, finalFileName(key))
|
||||
done += when {
|
||||
part.isFile && part.length() > 0 -> if (size > 0) part.length().coerceAtMost(size) else 0L
|
||||
final.isFile && final.length() > 0 -> if (size > 0) size else final.length()
|
||||
else -> 0L
|
||||
}
|
||||
}
|
||||
return ((done * 100) / totalSum).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
/** Процент одного файла [fileName] по .part/финальному файлу относительно его размера; null, если посчитать нельзя. */
|
||||
internal fun filePercent(dir: File, fileName: String, sizes: Map<String, Long>): Int? {
|
||||
val final = File(dir, fileName)
|
||||
if (final.isFile && final.length() > 0) return 100
|
||||
val part = File(dir, "$fileName.part")
|
||||
if (part.isFile && part.length() > 0) {
|
||||
val total = sizes[fileKeyFor(part.name)] ?: 0L
|
||||
if (total > 0) return ((part.length() * 100) / total).toInt().coerceIn(0, 99)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Общий процент по живым суммам байтов (скачано/всего по всем файлам). */
|
||||
internal fun combinedPercent(totals: Map<String, Long>, done: Map<String, Long>): Int {
|
||||
val totalSum = totals.values.sum()
|
||||
if (totalSum <= 0) return 0
|
||||
val doneSum = done.values.sum()
|
||||
return ((doneSum * 100) / totalSum).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Скан папок filesDir/viewmate/<itemId>: все ожидаемые файлы → itemId в downloaded
|
||||
* (заодно чистим лишние .part); есть только .part → фаза downloading с общим процентом
|
||||
* по всем дорожкам. Список дорожек — из статуса (expectedFiles), а не с диска.
|
||||
*/
|
||||
internal fun scanDownloads(
|
||||
baseDir: File,
|
||||
@@ -96,17 +188,23 @@ internal fun scanDownloads(
|
||||
baseDir.listFiles()?.forEach { dir ->
|
||||
if (!dir.isDirectory) return@forEach
|
||||
val itemId = dir.name
|
||||
val savedStatus = saved[itemId]
|
||||
val keys = savedStatus?.expectedFiles.orEmpty()
|
||||
val parts = dir.listFiles { f -> f.isFile && f.name.endsWith(".part") }?.toList().orEmpty()
|
||||
val biggestPart = parts.maxByOrNull { it.length() }
|
||||
val hasFinal = File(dir, "video.mkv").exists()
|
||||
|
||||
if (hasFinal) {
|
||||
// старый формат (без expectedFiles): полным считаем наличие video.mkv
|
||||
val fully = if (keys.isNotEmpty()) isFullyOnDisk(dir, keys) else File(dir, "video.mkv").isFile
|
||||
if (fully) {
|
||||
downloaded += itemId
|
||||
parts.forEach { it.delete() }
|
||||
} else if (biggestPart != null && biggestPart.length() > 0) {
|
||||
val savedStatus = saved[itemId]
|
||||
} 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 percent = if (total > 0) ((biggestPart.length() * 100) / total).toInt().coerceIn(0, 99) else 0
|
||||
val biggest = parts.maxByOrNull { it.length() } ?: return@forEach
|
||||
if (total > 0) ((biggest.length() * 100) / total).toInt().coerceIn(0, 99) else 0
|
||||
}
|
||||
val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "видео"
|
||||
progress[itemId] = DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, stage)
|
||||
}
|
||||
@@ -123,7 +221,7 @@ internal fun requestFor(url: String, existingBytes: Long): Request {
|
||||
}
|
||||
|
||||
/**
|
||||
* Реальное скачивание зеркала (видео + выбранная озвучка) с прогрессом.
|
||||
* Реальное скачивание зеркала (видео + все аудио-дорожки) с общим прогрессом.
|
||||
* Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
|
||||
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
|
||||
* при старте диск сканируется, докачка — только по явному тапу пользователя.
|
||||
@@ -135,6 +233,13 @@ class DownloadManager(
|
||||
private val baseDir = File(context.filesDir, "viewmate")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val jobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
/**
|
||||
* Поколение запуска качки itemId. Растёт при [delete]: незавершённая корутина
|
||||
* после удаления видит несовпадение поколений и «умирает», не создавая файлы/статус заново.
|
||||
*/
|
||||
private val generations = ConcurrentHashMap<String, Long>()
|
||||
|
||||
private val statuses = ConcurrentHashMap<String, DownloadStatus>()
|
||||
private val statusStore = DownloadStatusStore(File(baseDir, "status.json"))
|
||||
private val okHttp = OkHttpClient.Builder()
|
||||
@@ -180,6 +285,8 @@ class DownloadManager(
|
||||
percent = p.percent,
|
||||
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
expectedFiles = statuses[itemId]?.expectedFiles ?: emptyList(),
|
||||
fileSizes = statuses[itemId]?.fileSizes ?: emptyMap(),
|
||||
)
|
||||
}
|
||||
statusStore.write(statuses)
|
||||
@@ -213,74 +320,122 @@ class DownloadManager(
|
||||
/** Видео уже скачано на телефон. */
|
||||
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists()
|
||||
|
||||
/** Процент неполного файла [part] от полного размера (totalBytes из статуса). */
|
||||
fun partPercent(itemId: String, part: File): Int {
|
||||
if (!part.exists() || part.length() <= 0) return 0
|
||||
val total = statuses[itemId]?.totalBytes ?: 0L
|
||||
if (total <= 0) return 0
|
||||
return ((part.length() * 100) / total).toInt().coerceIn(0, 99)
|
||||
/**
|
||||
* Полностью ли скачано зеркало: video.mkv + все аудио из [files] (без .part, >0 байт).
|
||||
* Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске.
|
||||
*/
|
||||
fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean {
|
||||
val keys = files?.let { fileKeys(it) } ?: return false
|
||||
if (keys.isEmpty()) return false
|
||||
return isFullyOnDisk(dirFor(itemId), keys)
|
||||
}
|
||||
|
||||
/** Общий процент скачанного на диск по всем дорожкам (по .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)
|
||||
}
|
||||
|
||||
/** Известные размеры дорожек (key → байт) после скачивания на телефон; пусто, если качки не было. */
|
||||
fun knownFileSizes(itemId: String): Map<String, Long> =
|
||||
statuses[itemId]?.fileSizes.orEmpty()
|
||||
|
||||
/** Процент конкретного файла [fileName] ("video.mkv", "audio-0.ogg") на диске телефона; null, если посчитать нельзя. */
|
||||
fun percentForFile(itemId: String, fileName: String): Int? {
|
||||
val st = statuses[itemId]
|
||||
return filePercent(dirFor(itemId), fileName, st?.fileSizes.orEmpty())
|
||||
}
|
||||
|
||||
/** Процент неполного файла [part] от полного размера дорожки (fileSizes/totalBytes из статуса). */
|
||||
fun partPercent(itemId: String, part: File): Int =
|
||||
percentForFile(itemId, part.name.removeSuffix(".part")) ?: 0
|
||||
|
||||
/**
|
||||
* Начать (или продолжить) загрузку зеркала [itemId] (видео + озвучка [audioIndex]).
|
||||
* Если уже качается — повторный вызов игнорируется; если файл полный — тоже.
|
||||
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-).
|
||||
* Начать (или продолжить) загрузку зеркала [itemId]: видео + ВСЕ аудио-дорожки.
|
||||
* Если уже качается — повторный вызов игнорируется; если всё полностью скачано — тоже.
|
||||
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-) для каждого файла.
|
||||
*/
|
||||
fun startDownload(itemId: String, audioIndex: Int) {
|
||||
fun startDownload(itemId: String) {
|
||||
if (jobs[itemId]?.isActive == true) return
|
||||
if (isDownloaded(itemId)) return
|
||||
val generation = generations[itemId] ?: 0L
|
||||
|
||||
jobs[itemId] = scope.launch {
|
||||
lateinit var job: Job
|
||||
job = scope.launch {
|
||||
_activeIds.value = _activeIds.value + itemId
|
||||
try {
|
||||
// корутина «устарела»: itemId удалили (delete) — дальше не идём,
|
||||
// чтобы не создать заново каталог/статус/.part после удаления
|
||||
fun stale() = !isCurrentGeneration(generations, itemId, generation)
|
||||
if (stale()) return@launch
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, resumePercent(itemId), "получаю зеркало"))
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
if (stale()) return@launch
|
||||
val files = status?.files
|
||||
if (status == null || status.status != "done" || files == null) {
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
|
||||
if (!stale()) update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
|
||||
return@launch
|
||||
}
|
||||
|
||||
val keys = fileKeys(files)
|
||||
val dir = dirFor(itemId)
|
||||
if (stale()) return@launch
|
||||
dir.mkdirs()
|
||||
|
||||
downloadFile(
|
||||
url = S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||
part = partVideo(itemId),
|
||||
dest = videoFile(itemId),
|
||||
onProgress = { percent ->
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "видео"))
|
||||
},
|
||||
onTotal = { total -> setTotalBytes(itemId, total) },
|
||||
)
|
||||
if (isFullyOnDisk(dir, keys)) {
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
|
||||
_downloadedIds.value = _downloadedIds.value + itemId
|
||||
log("download", "$itemId уже полностью скачан на диск")
|
||||
return@launch
|
||||
}
|
||||
setExpectedFiles(itemId, keys)
|
||||
|
||||
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull()
|
||||
if (audio != null) {
|
||||
val totals = HashMap<String, Long>()
|
||||
val done = HashMap<String, Long>()
|
||||
keys.forEach { key ->
|
||||
if (stale()) return@launch
|
||||
val url = if (key == "video") files.video.url
|
||||
else files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach
|
||||
val stage = if (key == "video") "видео" else "звук"
|
||||
downloadFile(
|
||||
url = S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||
part = partAudio(itemId, audio.index),
|
||||
dest = audioFile(itemId, audio.index),
|
||||
onProgress = { percent ->
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "звук"))
|
||||
url = S3Presigner.presignGet(url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||
part = File(dir, partFileName(key)),
|
||||
dest = File(dir, finalFileName(key)),
|
||||
onTotal = { total ->
|
||||
totals[key] = total
|
||||
setFileSize(itemId, key, total)
|
||||
},
|
||||
onProgress = { d, t ->
|
||||
totals[key] = t
|
||||
done[key] = d
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, combinedPercent(totals, done), stage))
|
||||
},
|
||||
onTotal = { total -> setTotalBytes(itemId, total) },
|
||||
)
|
||||
if (stale()) return@launch
|
||||
done[key] = totals[key] ?: 0L
|
||||
}
|
||||
|
||||
if (stale()) return@launch
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
|
||||
_downloadedIds.value = _downloadedIds.value + itemId
|
||||
log("download", "скачано $itemId → $dir")
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
// после delete() не воскрешаем статус «отменено» — состояние уже сброшено
|
||||
if (isCurrentGeneration(generations, itemId, generation)) {
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено"))
|
||||
}
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (isCurrentGeneration(generations, itemId, generation)) {
|
||||
log("download", "ошибка скачивания $itemId: ${e.message}")
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
|
||||
}
|
||||
} finally {
|
||||
jobs.remove(itemId)
|
||||
// снимаем ТОЛЬКО свою корутину: после cancel/delete в jobs могла лечь новая
|
||||
jobs.remove(itemId, job)
|
||||
_activeIds.value = _activeIds.value - itemId
|
||||
}
|
||||
}
|
||||
jobs[itemId] = job
|
||||
}
|
||||
|
||||
/** Отменить текущую загрузку (.part остаётся — при следующем тапе докачается). */
|
||||
@@ -288,32 +443,34 @@ class DownloadManager(
|
||||
jobs.remove(itemId)?.cancel()
|
||||
}
|
||||
|
||||
/** Удалить скачанные файлы с телефона и сбросить статус. */
|
||||
/** Удалить скачанные файлы с телефона и сбросить статус. Удаление финально. */
|
||||
fun delete(itemId: String) {
|
||||
cancel(itemId)
|
||||
// инвалидируем незавершённую корутину: даже если она проснётся позже — дальше не пойдёт
|
||||
generations[itemId] = (generations[itemId] ?: 0L) + 1
|
||||
dirFor(itemId).deleteRecursively()
|
||||
_downloads.value = _downloads.value - itemId
|
||||
_downloadedIds.value = _downloadedIds.value - itemId
|
||||
statuses.remove(itemId)
|
||||
val downloads = _downloads.value.toMutableMap()
|
||||
val downloadedIds = _downloadedIds.value.toMutableSet()
|
||||
removeDownloadState(statuses, downloads, downloadedIds, itemId)
|
||||
_downloads.value = downloads
|
||||
_downloadedIds.value = downloadedIds
|
||||
_activeIds.value = _activeIds.value - itemId
|
||||
persistStatus(force = true)
|
||||
log("download", "удалено скачанное $itemId (поколение → ${generations[itemId]})")
|
||||
}
|
||||
|
||||
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке). */
|
||||
private fun resumePercent(itemId: String): Int {
|
||||
val total = statuses[itemId]?.totalBytes ?: 0L
|
||||
if (total <= 0) return 0
|
||||
return partPercent(itemId, partVideo(itemId))
|
||||
}
|
||||
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке) — общий процент по всем дорожкам. */
|
||||
private fun resumePercent(itemId: String): Int = overallPercent(itemId)
|
||||
|
||||
/**
|
||||
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
|
||||
* по завершении — rename в [dest] (без .part).
|
||||
* по завершении — rename в [dest] (без .part). [onProgress] отдаёт (скачано, всего).
|
||||
*/
|
||||
private suspend fun downloadFile(
|
||||
url: String,
|
||||
part: File,
|
||||
dest: File,
|
||||
onProgress: (Int) -> Unit,
|
||||
onProgress: (done: Long, total: Long) -> Unit,
|
||||
onTotal: (Long) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val existing = if (part.exists()) part.length() else 0L
|
||||
@@ -334,6 +491,8 @@ class DownloadManager(
|
||||
onTotal(total)
|
||||
|
||||
val input = body.byteStream()
|
||||
// перед созданием/открытием .part — стоп, если качку отменили/удалили
|
||||
currentCoroutineContext().ensureActive()
|
||||
FileOutputStream(part, resumed).use { out ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var done = from
|
||||
@@ -343,13 +502,13 @@ class DownloadManager(
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
if (total > 0) {
|
||||
val percent = ((done * 100) / total).toInt().coerceIn(0, 100)
|
||||
onProgress(percent)
|
||||
}
|
||||
if (total > 0) onProgress(done, total)
|
||||
}
|
||||
}
|
||||
|
||||
// перед финализацией (.part → конечный файл) — стоп, если качку отменили/удалили,
|
||||
// иначе удаление «воскреснет» переименованным .part
|
||||
currentCoroutineContext().ensureActive()
|
||||
if (dest.exists()) dest.delete()
|
||||
if (!part.renameTo(dest)) {
|
||||
part.copyTo(dest, overwrite = true)
|
||||
@@ -358,22 +517,49 @@ class DownloadManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setTotalBytes(itemId: String, total: Long) {
|
||||
if (total <= 0) return
|
||||
private fun setFileSize(itemId: String, key: String, size: Long) {
|
||||
if (size <= 0) return
|
||||
val prev = statuses[itemId]
|
||||
if (prev != null && prev.totalBytes > 0) return
|
||||
statuses[itemId] = prev?.copy(totalBytes = total) ?: DownloadStatus(totalBytes = total)
|
||||
val sizes = prev?.fileSizes ?: emptyMap()
|
||||
if (sizes.containsKey(key)) return
|
||||
statuses[itemId] = DownloadStatus(
|
||||
phase = prev?.phase ?: "downloading",
|
||||
stage = prev?.stage ?: "",
|
||||
percent = prev?.percent ?: 0,
|
||||
totalBytes = (prev?.totalBytes ?: 0L) + size,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
expectedFiles = prev?.expectedFiles ?: emptyList(),
|
||||
fileSizes = sizes + (key to size),
|
||||
)
|
||||
persistStatus()
|
||||
}
|
||||
|
||||
private fun setExpectedFiles(itemId: String, keys: List<String>) {
|
||||
val prev = statuses[itemId]
|
||||
if (prev?.expectedFiles == keys) return
|
||||
statuses[itemId] = DownloadStatus(
|
||||
phase = prev?.phase ?: "downloading",
|
||||
stage = prev?.stage ?: "",
|
||||
percent = prev?.percent ?: 0,
|
||||
totalBytes = prev?.totalBytes ?: 0L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
expectedFiles = keys,
|
||||
fileSizes = prev?.fileSizes ?: emptyMap(),
|
||||
)
|
||||
persistStatus()
|
||||
}
|
||||
|
||||
private fun update(itemId: String, progress: DownloadProgress) {
|
||||
_downloads.value = _downloads.value + (itemId to progress)
|
||||
val prev = statuses[itemId]
|
||||
statuses[itemId] = DownloadStatus(
|
||||
phase = progress.phase.name.lowercase(),
|
||||
stage = progress.stage,
|
||||
percent = progress.percent,
|
||||
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
|
||||
totalBytes = prev?.totalBytes ?: 0L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
expectedFiles = prev?.expectedFiles ?: emptyList(),
|
||||
fileSizes = prev?.fileSizes ?: emptyMap(),
|
||||
)
|
||||
persistStatus(force = progress.phase != DownloadPhase.DOWNLOADING)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||
import java.io.File
|
||||
|
||||
/** Статус файла на устройстве (телефон или очки) для строк «скачивание на устройства». */
|
||||
enum class FileDownloadState {
|
||||
NONE,
|
||||
DOWNLOADING,
|
||||
DONE,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
/** Статус файла на очках из последнего DownloadProgress (null — не начат). */
|
||||
fun glassesFileState(progress: DownloadProgress?): Pair<FileDownloadState, Int> {
|
||||
if (progress == null) return FileDownloadState.NONE to 0
|
||||
return when (progress.phase) {
|
||||
DownloadPhase.DONE -> FileDownloadState.DONE to 100
|
||||
DownloadPhase.DOWNLOADING -> FileDownloadState.DOWNLOADING to progress.percent.coerceIn(0, 99)
|
||||
DownloadPhase.FAILED -> FileDownloadState.FAILED to 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Статус файла на телефоне: финальный файл есть → DONE; .part есть → DOWNLOADING. */
|
||||
fun phoneFileState(finalFile: File, partFile: File): Pair<FileDownloadState, Int> {
|
||||
if (finalFile.isFile && finalFile.length() > 0) return FileDownloadState.DONE to 100
|
||||
if (partFile.isFile && partFile.length() > 0) return FileDownloadState.DOWNLOADING to 0
|
||||
return FileDownloadState.NONE to 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Общий процент скачивания на очки (для кнопки «Скачать на очки»).
|
||||
* Сначала — по известным размерам дорожек [knownSizes] (из качки на телефон);
|
||||
* если размеров нет — по прогрессу уже начатых файлов.
|
||||
*/
|
||||
fun glassesOverallPercent(
|
||||
fileNames: List<String>,
|
||||
knownSizes: Map<String, Long>,
|
||||
perFile: Map<String, DownloadProgress>,
|
||||
): Int? {
|
||||
if (perFile.isEmpty()) return null
|
||||
val fullyDone = fileNames.isNotEmpty() &&
|
||||
perFile.keys.containsAll(fileNames) &&
|
||||
perFile.values.all { it.phase == DownloadPhase.DONE }
|
||||
if (fullyDone) return 100
|
||||
|
||||
val total = fileNames.sumOf { knownSizes[it] ?: 0L }
|
||||
if (total > 0) {
|
||||
val done = fileNames.sumOf { name ->
|
||||
val p = perFile[name]
|
||||
when {
|
||||
p == null -> 0L
|
||||
p.phase == DownloadPhase.DONE -> if (p.totalBytes > 0) p.totalBytes else knownSizes[name] ?: 0L
|
||||
else -> p.doneBytes
|
||||
}
|
||||
}
|
||||
return ((done * 100) / total).toInt().coerceIn(0, 99)
|
||||
}
|
||||
|
||||
val totalStarted = perFile.values.sumOf { it.totalBytes }
|
||||
if (totalStarted <= 0) return null
|
||||
val doneStarted = perFile.values.sumOf { it.doneBytes }
|
||||
return ((doneStarted * 100) / totalStarted).toInt().coerceIn(0, 99)
|
||||
}
|
||||
|
||||
/** Простой «живой» процент по текущим прогрессам (для индикатора на экране «Очки»). */
|
||||
fun glassesLivePercent(perFile: Map<String, DownloadProgress>): Int? {
|
||||
if (perFile.isEmpty()) return null
|
||||
if (perFile.values.all { it.phase == DownloadPhase.DONE }) return 100
|
||||
val total = perFile.values.sumOf { it.totalBytes }
|
||||
if (total <= 0) return null
|
||||
val done = perFile.values.sumOf { it.doneBytes }
|
||||
return ((done * 100) / total).toInt().coerceIn(0, 99)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import kotlinx.serialization.encodeToString
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.phone.GlassesSender
|
||||
import pw.binom.viewmate.core.phone.PhoneState
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||
import pw.binom.viewmate.core.protocol.GlassesOff
|
||||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
@@ -36,7 +37,8 @@ const val GLASSES_WS_PATH = "/ws/glasses"
|
||||
/**
|
||||
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
||||
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
|
||||
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза).
|
||||
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
|
||||
* DownloadProgress → карта скачивания на очки).
|
||||
*/
|
||||
class GlassesHub(
|
||||
private val state: PhoneState,
|
||||
@@ -58,6 +60,29 @@ class GlassesHub(
|
||||
private val _storageTotalGb = MutableStateFlow<Double?>(null)
|
||||
val storageTotalGb: StateFlow<Double?> = _storageTotalGb.asStateFlow()
|
||||
|
||||
private val _storageTotalBytes = MutableStateFlow<Long?>(null)
|
||||
val storageTotalBytes: StateFlow<Long?> = _storageTotalBytes.asStateFlow()
|
||||
|
||||
private val _storageFreeBytes = MutableStateFlow<Long?>(null)
|
||||
val storageFreeBytes: StateFlow<Long?> = _storageFreeBytes.asStateFlow()
|
||||
|
||||
private val _mediaBytes = MutableStateFlow<Long?>(null)
|
||||
val mediaBytes: StateFlow<Long?> = _mediaBytes.asStateFlow()
|
||||
|
||||
private val _appVersion = MutableStateFlow<String?>(null)
|
||||
val appVersion: StateFlow<String?> = _appVersion.asStateFlow()
|
||||
|
||||
private val _mediaDir = MutableStateFlow<String?>(null)
|
||||
val mediaDir: StateFlow<String?> = _mediaDir.asStateFlow()
|
||||
|
||||
private val _downloadedItemIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
/** itemId полностью скачанных на очки (из периодического GlassesStatus). */
|
||||
val downloadedItemIds: StateFlow<Set<String>> = _downloadedItemIds.asStateFlow()
|
||||
|
||||
private val _glassesDownloads = MutableStateFlow<Map<String, Map<String, DownloadProgress>>>(emptyMap())
|
||||
/** Прогресс скачивания на очки: itemId → имя файла → последний DownloadProgress от очков. */
|
||||
val glassesDownloads: StateFlow<Map<String, Map<String, DownloadProgress>>> = _glassesDownloads.asStateFlow()
|
||||
|
||||
private val _positionMs = MutableStateFlow<Long?>(null)
|
||||
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
|
||||
|
||||
@@ -106,7 +131,24 @@ class GlassesHub(
|
||||
_batteryPercent.value = msg.batteryPercent
|
||||
_storageUsedGb.value = msg.storageUsedGb
|
||||
_storageTotalGb.value = msg.storageTotalGb
|
||||
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
|
||||
if (msg.storageTotalBytes > 0) _storageTotalBytes.value = msg.storageTotalBytes
|
||||
if (msg.storageFreeBytes > 0) _storageFreeBytes.value = msg.storageFreeBytes
|
||||
if (msg.mediaBytes > 0) _mediaBytes.value = msg.mediaBytes
|
||||
if (msg.mediaDir.isNotBlank()) _mediaDir.value = msg.mediaDir
|
||||
if (msg.appVersion.isNotBlank()) _appVersion.value = msg.appVersion
|
||||
_downloadedItemIds.value = msg.downloadedItemIds.toSet()
|
||||
log(
|
||||
"glasses",
|
||||
"статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ, " +
|
||||
"медиа ${msg.mediaBytes} байт, скачано: ${msg.downloadedItemIds.size}, app=${msg.appVersion}",
|
||||
)
|
||||
}
|
||||
|
||||
is DownloadProgress -> {
|
||||
val prev = _glassesDownloads.value[msg.itemId] ?: emptyMap()
|
||||
val updated = _glassesDownloads.value + (msg.itemId to (prev + (msg.fileName to msg)))
|
||||
_glassesDownloads.value = updated
|
||||
log("glasses", "скачивание на очки: ${msg.itemId} ${msg.fileName} ${msg.percent}% (${msg.phase})")
|
||||
}
|
||||
|
||||
is PlaybackPosition -> {
|
||||
|
||||
@@ -37,6 +37,7 @@ enum class Screen {
|
||||
CATALOG,
|
||||
DETAILS,
|
||||
GLASSES,
|
||||
GLASSES_INFO,
|
||||
CHAT,
|
||||
}
|
||||
|
||||
@@ -161,6 +162,18 @@ fun msToClock(ms: Long): String {
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
|
||||
}
|
||||
|
||||
/** Байты → «1.86 ГБ» / «65 МБ» / «--» если размер неизвестен. */
|
||||
fun bytesToSize(bytes: Long?): String {
|
||||
if (bytes == null || bytes <= 0) return "--"
|
||||
val gb = 1024.0 * 1024 * 1024
|
||||
val mb = 1024.0 * 1024
|
||||
return when {
|
||||
bytes >= gb -> "%.2f ГБ".format(bytes / gb)
|
||||
bytes >= mb -> "%d МБ".format((bytes / mb).toLong())
|
||||
else -> "%d КБ".format((bytes / 1024).toLong())
|
||||
}
|
||||
}
|
||||
|
||||
/** Заглушка-подложка скрина (loading / error / пусто). */
|
||||
@Composable
|
||||
fun ScreenPlaceholder(text: String, modifier: Modifier = Modifier) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -41,11 +42,21 @@ import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.core.media.MirrorStatus
|
||||
import pw.binom.viewmate.phone.DownloadPhase
|
||||
import pw.binom.viewmate.core.phone.trackFileName
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.phone.FileDownloadState
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.PhoneConfig
|
||||
import pw.binom.viewmate.phone.S3Presigner
|
||||
import pw.binom.viewmate.phone.fileKeys
|
||||
import pw.binom.viewmate.phone.finalFileName
|
||||
import pw.binom.viewmate.phone.glassesFileState
|
||||
import pw.binom.viewmate.phone.glassesOverallPercent
|
||||
import pw.binom.viewmate.phone.log
|
||||
import pw.binom.viewmate.phone.phoneFileState
|
||||
import pw.binom.viewmate.phone.resolveAudioSource
|
||||
import pw.binom.viewmate.phone.resolveAudioSourceDetailed
|
||||
import java.io.File
|
||||
|
||||
/** Экран 2 — Детали элемента: озвучка, статус конвертации, скачивание, кнопки. */
|
||||
@Composable
|
||||
@@ -63,11 +74,10 @@ fun DetailsScreen(
|
||||
var refreshKey by remember(item.Id) { mutableStateOf(0) }
|
||||
var audioIndex by remember(item.Id) { mutableStateOf(app.state.currentAudioIndex) }
|
||||
|
||||
val downloadProgress by app.downloadManager.downloads.collectAsState()
|
||||
val activeIds by app.downloadManager.activeIds.collectAsState()
|
||||
val downloadedMap by app.state.downloadedFlow.collectAsState()
|
||||
val localIds by app.downloadManager.downloadedIds.collectAsState()
|
||||
var audioDownloaded by remember(item.Id) { mutableStateOf(app.downloadManager.audioFiles(item.Id).isNotEmpty()) }
|
||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||
val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
|
||||
|
||||
// статус зеркала + дорожки; пока конвертируется — опрашиваем
|
||||
LaunchedEffect(item.Id, refreshKey) {
|
||||
@@ -82,23 +92,29 @@ fun DetailsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// файлы озвучки появляются после завершения загрузки на телефон
|
||||
LaunchedEffect(item.Id, downloadProgress) {
|
||||
audioDownloaded = app.downloadManager.audioFiles(item.Id).isNotEmpty()
|
||||
}
|
||||
|
||||
val dm = app.downloadManager
|
||||
val files = mirrorStatus?.files
|
||||
val audios = files?.audios.orEmpty()
|
||||
val effectiveAudioIndex = if (audios.any { it.index == audioIndex }) audioIndex else audios.firstOrNull()?.index ?: 0
|
||||
val download = downloadProgress[item.Id]
|
||||
// «качается» только если идёт активное скачивание; восстановленный из статуса .part — не качается
|
||||
val downloading = activeIds.contains(item.Id)
|
||||
val videoPart = app.downloadManager.partVideo(item.Id)
|
||||
val hasVideoPart = videoPart.length() > 0
|
||||
val audioPartFiles = app.downloadManager.audioParts(item.Id)
|
||||
|
||||
val videoPart = dm.partVideo(item.Id)
|
||||
val hasVideoPart = videoPart.isFile && videoPart.length() > 0
|
||||
val audioPartFiles = dm.audioParts(item.Id)
|
||||
val hasAudioPart = audioPartFiles.any { it.length() > 0 }
|
||||
val videoPartPercent = app.downloadManager.partPercent(item.Id, videoPart)
|
||||
val audioPartPercent = audioPartFiles.maxOfOrNull { app.downloadManager.partPercent(item.Id, it) } ?: 0
|
||||
val hasAnyPart = hasVideoPart || hasAudioPart
|
||||
val fullyDownloaded = dm.isFullyDownloaded(item.Id, files)
|
||||
val overallPartPercent = dm.overallPercent(item.Id)
|
||||
|
||||
// --- статус контента на очках (из расширенного GlassesStatus + DownloadProgress) ---
|
||||
val keys = files?.let { fileKeys(it) }.orEmpty()
|
||||
val fileNames = keys.map { finalFileName(it) }
|
||||
val knownSizeByName = dm.knownFileSizes(item.Id).mapKeys { (k, _) -> finalFileName(k) }
|
||||
val perFileProgress = glassesDownloads[item.Id].orEmpty()
|
||||
val glassesFully = glassesDownloadedIds.contains(item.Id)
|
||||
val downloadingOnGlasses = !glassesFully && perFileProgress.values.any { it.phase == DownloadPhase.DOWNLOADING }
|
||||
val glassesOverall = glassesOverallPercent(fileNames, knownSizeByName, perFileProgress)
|
||||
|
||||
fun onSelectAudio(index: Int) {
|
||||
audioIndex = index
|
||||
@@ -114,6 +130,86 @@ fun DetailsScreen(
|
||||
}
|
||||
|
||||
fun onWatch() {
|
||||
// звук уже играет — повторный тап не должен пересоздавать аудио-плеер.
|
||||
// state.playing в guard НЕ включаем: он остаётся true после рестарта телефона
|
||||
// (очки продолжают играть), когда аудио-плеер ещё не создан, и блокировал бы запуск звука.
|
||||
if (app.audioSync.isActive) {
|
||||
log("watch", "пропущено: аудио-плеер уже активен (audioSync.isActive=true)")
|
||||
scope.launch { snackbar.showSnackbar("уже играет") }
|
||||
return
|
||||
}
|
||||
if (!glassesFully) {
|
||||
log("watch", "пропущено: контент не скачан на очки (glassesFully=false)")
|
||||
scope.launch { snackbar.showSnackbar("сначала нажми «Скачать на очки»") }
|
||||
return
|
||||
}
|
||||
if (glassesMediaDir.isNullOrBlank()) {
|
||||
log("watch", "пропущено: очки ещё не сообщили каталог (mediaDir пуст)")
|
||||
scope.launch { snackbar.showSnackbar("очки ещё не сообщили каталог — подожди") }
|
||||
return
|
||||
}
|
||||
val mediaDir = glassesMediaDir!!
|
||||
scope.launch {
|
||||
if (app.server.hub.connected.value == 0) {
|
||||
log("watch", "пропущено: очки не подключены (connected=0)")
|
||||
snackbar.showSnackbar("очки не подключены")
|
||||
return@launch
|
||||
}
|
||||
val actions = app.actions
|
||||
// видео на очках уже играет (последний PlaybackPosition) — PlayVideo не пересылаем,
|
||||
// чтобы не перезапускать фильм: подключаем только звук с текущей позиции очков
|
||||
val alreadyPlaying = app.state.playing
|
||||
if (!alreadyPlaying && actions == null) {
|
||||
log("watch", "пропущено: телефон ещё не готов (actions=null)")
|
||||
snackbar.showSnackbar("телефон ещё не готов")
|
||||
return@launch
|
||||
}
|
||||
// PlayVideo — только локальный путь на очках, никаких S3-URL очкам
|
||||
val localPath = "file://$mediaDir/${item.Id}/video.mkv"
|
||||
val result: String
|
||||
if (alreadyPlaying) {
|
||||
result = "включил (видео уже играет на очках)"
|
||||
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
|
||||
} else {
|
||||
result = try {
|
||||
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath)
|
||||
} catch (e: Exception) {
|
||||
log("watch", "watch упал: ${e.message}")
|
||||
snackbar.showSnackbar("watch упал: ${e.message}")
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
log("watch", "watch result: $result")
|
||||
if (result.contains("включил")) {
|
||||
// зеркало в композиции могло не догрузиться — подстрахуемся свежим статусом
|
||||
var mirrorFiles = files
|
||||
if (mirrorFiles == null) {
|
||||
mirrorFiles = runCatching { app.mirror.mirrorByItem(item.Id)?.files }.getOrNull()
|
||||
log("watch", "files в композиции не было — запросил зеркало заново: ${if (mirrorFiles == null) "всё ещё null" else "получено"}")
|
||||
}
|
||||
// звук — на телефоне: запускаем ту же дорожку (локально или стриминг)
|
||||
val audio = resolveAudioSourceDetailed(
|
||||
mirrorFiles = mirrorFiles,
|
||||
audioIndex = effectiveAudioIndex,
|
||||
localAudioFile = { dm.audioFile(item.Id, it) },
|
||||
)
|
||||
if (audio.source == null) {
|
||||
log("watch", "audio source: null — ${audio.reason}")
|
||||
snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}")
|
||||
} else {
|
||||
log("watch", "audio source: ${audio.source} (${audio.reason})")
|
||||
val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else 0L
|
||||
app.audioSync.play(audio.source, startPositionMs)
|
||||
}
|
||||
} else {
|
||||
log("watch", "watch отклонён телефоном: $result")
|
||||
snackbar.showSnackbar(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onDownloadToGlasses() {
|
||||
if (glassesFully) return
|
||||
scope.launch {
|
||||
if (app.server.hub.connected.value == 0) {
|
||||
snackbar.showSnackbar("очки не подключены")
|
||||
@@ -124,34 +220,29 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
scope.launch {
|
||||
app.downloadManager.delete(item.Id)
|
||||
app.actions?.deleteDownloaded(item.Id)
|
||||
}
|
||||
}
|
||||
|
||||
fun onOrderMirror() {
|
||||
scope.launch {
|
||||
@@ -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)
|
||||
if (keys.isEmpty()) {
|
||||
Text("нет дорожек зеркала", color = PhoneMuted, fontSize = 11.sp)
|
||||
} else {
|
||||
val trackFiles: Map<String, Pair<File, File>> = keys.associateWith { key ->
|
||||
if (key == "video") dm.videoFile(item.Id) to dm.partVideo(item.Id)
|
||||
else {
|
||||
val idx = key.removePrefix("audio-").toIntOrNull() ?: 0
|
||||
dm.audioFile(item.Id, idx) to dm.partAudio(item.Id, idx)
|
||||
}
|
||||
StatusLine("🕶️ Очки — звук") {
|
||||
DeviceChip(downloaded = onGlasses, downloading = false, percent = 0)
|
||||
}
|
||||
StatusLine("📱 Телефон — видео") {
|
||||
DeviceChip(
|
||||
downloaded = onPhoneVideo,
|
||||
downloading = !onPhoneVideo && hasVideoPart,
|
||||
percent = download?.percent ?: videoPartPercent,
|
||||
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,
|
||||
)
|
||||
}
|
||||
StatusLine("📱 Телефон — звук") {
|
||||
DeviceChip(
|
||||
downloaded = onPhoneAudio,
|
||||
downloading = !onPhoneAudio && hasAudioPart,
|
||||
percent = download?.percent ?: audioPartPercent,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedButton(
|
||||
@@ -320,11 +423,37 @@ fun DetailsScreen(
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||
Button(
|
||||
onClick = ::onWatch,
|
||||
enabled = glassesFully,
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PhoneAccent, contentColor = PhoneBackground),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Text("▶ Смотреть на очках", fontSize = 13.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
when {
|
||||
downloadingOnGlasses -> "⏳ качается на очки, погоди"
|
||||
else -> "▶ Смотреть на очках"
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Button(
|
||||
onClick = ::onDownloadToGlasses,
|
||||
enabled = !glassesFully && !downloadingOnGlasses,
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PhoneSurface, contentColor = PhoneTextDim),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
glassesFully -> "✓ скачано на очки"
|
||||
downloadingOnGlasses -> "⏳ качается на очки… ${glassesOverall ?: 0}%"
|
||||
glassesOverall != null -> "Продолжить на очки ($glassesOverall%)"
|
||||
else -> "Скачать на очки"
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedButton(
|
||||
@@ -338,9 +467,9 @@ fun DetailsScreen(
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
localIds.contains(item.Id) -> "✓ скачано на телефон"
|
||||
fullyDownloaded -> "✓ скачано на телефон"
|
||||
downloading -> "Отменить"
|
||||
hasVideoPart -> "Продолжить ($videoPartPercent%)"
|
||||
hasAnyPart -> "Продолжить ($overallPartPercent%)"
|
||||
else -> "Скачать на телефон"
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
@@ -356,12 +485,49 @@ fun DetailsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Метка «размер/прогресс» для строки файла: при качке — ⏳ N%, иначе размер («--», если размера ещё нет). */
|
||||
private fun fileSizeOrProgress(
|
||||
sizeBytes: Long?,
|
||||
phone: Pair<FileDownloadState, Int>,
|
||||
glasses: Pair<FileDownloadState, Int>,
|
||||
): String = when {
|
||||
phone.first == FileDownloadState.DOWNLOADING -> "⏳ ${phone.second}%"
|
||||
glasses.first == FileDownloadState.DOWNLOADING -> "⏳ ${glasses.second}%"
|
||||
else -> bytesToSize(sizeBytes)
|
||||
}
|
||||
|
||||
/** Строка файла: имя · размер, ниже — чипы «телефон» / «очки». */
|
||||
@Composable
|
||||
private fun DeviceChip(downloaded: Boolean, downloading: Boolean, percent: Int) {
|
||||
when {
|
||||
downloaded -> StatusChip("✓ скачано", PhoneGreen, PhoneGreenText)
|
||||
downloading -> StatusChip("↓ качается… $percent%", PhoneYellow, PhoneBackground)
|
||||
else -> StatusChip("нет", PhoneSurface, PhoneMuted)
|
||||
private fun DownloadFileRow(
|
||||
name: String,
|
||||
sizeLabel: String,
|
||||
phone: Pair<FileDownloadState, Int>,
|
||||
glasses: Pair<FileDownloadState, Int>,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp)
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("телефон", color = PhoneMuted, fontSize = 9.sp)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
FileChip(phone)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text("очки", color = PhoneMuted, fontSize = 9.sp)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
FileChip(glasses)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileChip(state: Pair<FileDownloadState, Int>) {
|
||||
when (state.first) {
|
||||
FileDownloadState.DONE -> StatusChip("✓", PhoneGreen, PhoneGreenText)
|
||||
FileDownloadState.DOWNLOADING -> StatusChip("⏳ ${state.second}%", PhoneYellow, PhoneBackground)
|
||||
FileDownloadState.FAILED -> StatusChip("✗", PhoneRed, Color.White)
|
||||
FileDownloadState.NONE -> StatusChip("нет", PhoneSurface, PhoneMuted)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/** Экран «Информация об очках»: модель, батарея, связь, диск (медиа/свободно/прочее), версия. */
|
||||
@Composable
|
||||
fun GlassesInfoScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val hub = PhoneApp.instance.server.hub
|
||||
|
||||
val connected by hub.connected.collectAsState()
|
||||
val battery by hub.batteryPercent.collectAsState()
|
||||
val totalBytes by hub.storageTotalBytes.collectAsState()
|
||||
val freeBytes by hub.storageFreeBytes.collectAsState()
|
||||
val mediaBytes by hub.mediaBytes.collectAsState()
|
||||
val appVersion by hub.appVersion.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
text = "← назад",
|
||||
color = PhoneAccent,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onBack)
|
||||
.padding(6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Информация об очках",
|
||||
color = PhoneText,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
|
||||
StatusBlock {
|
||||
InfoRow("Модель", "RayNeo X2")
|
||||
InfoRow(
|
||||
"Связь",
|
||||
if (connected > 0) "подключено (WiFi)" else "не подключено",
|
||||
)
|
||||
InfoRow("Батарея", "${battery ?: "--"}%")
|
||||
InfoRow("Версия приложения", appVersion?.takeIf { it.isNotBlank() } ?: "—")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// состояние диска: занято / свободно / занято медиа
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||
SectionLabel("диск очков")
|
||||
StatusBlock {
|
||||
val total = totalBytes
|
||||
val free = freeBytes
|
||||
val media = mediaBytes
|
||||
if (total == null || total <= 0) {
|
||||
Text("очки не сообщили память", color = PhoneMuted, fontSize = 11.sp)
|
||||
} else {
|
||||
val used = (total - (free ?: 0L)).coerceIn(0L, total)
|
||||
val mediaClamped = media?.coerceIn(0L, used) ?: 0L
|
||||
val other = (used - mediaClamped).coerceAtLeast(0L)
|
||||
|
||||
// «медиа / свободно / прочее»
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(10.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(PhoneLine),
|
||||
) {
|
||||
if (mediaClamped > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(mediaClamped.toFloat())
|
||||
.background(PhoneAccent),
|
||||
)
|
||||
}
|
||||
if (other > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(other.toFloat())
|
||||
.background(PhoneRed),
|
||||
)
|
||||
}
|
||||
if ((free ?: 0L) > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight((free ?: 0L).toFloat())
|
||||
.background(PhoneGreen),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
InfoRow("Занято", bytesToSize(used))
|
||||
InfoRow("Свободно", bytesToSize(free ?: 0L))
|
||||
InfoRow("Занято медиа", bytesToSize(mediaClamped))
|
||||
InfoRow("Прочее", bytesToSize(other))
|
||||
Text(
|
||||
text = "доля медиа на диске: ${percentOf(mediaClamped, total)}%",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun percentOf(part: Long, total: Long): Int {
|
||||
if (total <= 0) return 0
|
||||
return ((part * 100.0) / total).roundToLong().toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
color = PhoneText,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,15 @@ import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.glassesLivePercent
|
||||
|
||||
/** Экран 3 — Статус очков + пульт. */
|
||||
@Composable
|
||||
fun GlassesScreen() {
|
||||
fun GlassesScreen(
|
||||
onOpenInfo: () -> Unit,
|
||||
) {
|
||||
val app = PhoneApp.instance
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -54,12 +58,18 @@ fun GlassesScreen() {
|
||||
val currentItem by app.state.currentItemFlow.collectAsState()
|
||||
val playing by app.state.playingFlow.collectAsState()
|
||||
val mode by app.state.modeFlow.collectAsState()
|
||||
val downloaded by app.state.downloadedFlow.collectAsState()
|
||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||
|
||||
var showDownloads by remember { mutableStateOf(false) }
|
||||
|
||||
val actions = app.actions
|
||||
|
||||
// активное скачивание на очки: первое с незавершённым файлом
|
||||
val activeGlassesItem = glassesDownloads.entries.firstOrNull { (_, perFile) ->
|
||||
perFile.values.any { it.phase != DownloadPhase.DONE }
|
||||
}
|
||||
val activeGlassesPercent = activeGlassesItem?.let { (_, perFile) -> glassesLivePercent(perFile) }
|
||||
|
||||
fun sendCommand(cmd: MediaCommand, seekMs: Long = 0) {
|
||||
val a = actions ?: return
|
||||
if (connected == 0) return
|
||||
@@ -98,8 +108,20 @@ fun GlassesScreen() {
|
||||
audioPositionMs = audioPos,
|
||||
durationMs = currentItem?.RunTimeTicks?.div(10_000),
|
||||
playing = playing,
|
||||
onClick = onOpenInfo,
|
||||
)
|
||||
|
||||
// активная загрузка контента на очки
|
||||
if (activeGlassesItem != null) {
|
||||
Text(
|
||||
text = "загрузка на очки: ${activeGlassesPercent ?: 0}%",
|
||||
color = PhoneYellow,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -152,6 +174,7 @@ private fun GlassesStatusCard(
|
||||
audioPositionMs: Long,
|
||||
durationMs: Long?,
|
||||
playing: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -159,6 +182,7 @@ private fun GlassesStatusCard(
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(PhoneCard)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// связь
|
||||
@@ -279,14 +303,15 @@ private fun OcuRow(icon: String, content: @Composable () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Диалог: список скачанного на очках (downloaded → «очки») с удалением. */
|
||||
/** Диалог: список скачанного на очках (реальный статус от очков) с удалением. */
|
||||
@Composable
|
||||
private fun GlassesDownloadsDialog(
|
||||
app: PhoneApp,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val downloaded by app.state.downloadedFlow.collectAsState()
|
||||
val glassesIds = downloaded.filterValues { it == "очки" }.keys.toList()
|
||||
val scope = rememberCoroutineScope()
|
||||
val downloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||
val glassesIds = downloadedIds.toList()
|
||||
|
||||
var names by remember(glassesIds) { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
LaunchedEffect(glassesIds) {
|
||||
@@ -318,7 +343,9 @@ private fun GlassesDownloadsDialog(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
app.actions?.deleteDownloaded(id)
|
||||
}
|
||||
}) {
|
||||
Text("удалить", color = PhoneRedText, fontSize = 11.sp)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ fun ViewMateApp() {
|
||||
Scaffold(
|
||||
containerColor = PhoneBackground,
|
||||
bottomBar = {
|
||||
if (screen != Screen.DETAILS) {
|
||||
if (screen != Screen.DETAILS && screen != Screen.GLASSES_INFO) {
|
||||
BottomNavBar(current = screen) { screen = it }
|
||||
}
|
||||
},
|
||||
@@ -62,7 +62,8 @@ fun ViewMateApp() {
|
||||
}
|
||||
}
|
||||
|
||||
Screen.GLASSES -> GlassesScreen()
|
||||
Screen.GLASSES -> GlassesScreen(onOpenInfo = { screen = Screen.GLASSES_INFO })
|
||||
Screen.GLASSES_INFO -> GlassesInfoScreen(onBack = { screen = Screen.GLASSES })
|
||||
Screen.CHAT -> ChatScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import pw.binom.viewmate.core.media.MirrorAudio
|
||||
import pw.binom.viewmate.core.media.MirrorFiles
|
||||
import pw.binom.viewmate.core.media.MirrorVideo
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AudioSyncPlayerTest {
|
||||
|
||||
@@ -59,4 +65,70 @@ class AudioSyncPlayerTest {
|
||||
assertNull(d.seekToMs)
|
||||
assertNull(d.playing)
|
||||
}
|
||||
|
||||
// --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») ---
|
||||
|
||||
private fun mirrorFiles(audios: List<MirrorAudio> = listOf()) = MirrorFiles(
|
||||
video = MirrorVideo(key = "v", url = "https://s3/mirror/video.mkv"),
|
||||
audios = audios,
|
||||
)
|
||||
|
||||
private fun audioDir(): File = Files.createTempDirectory("audio-src-test").toFile()
|
||||
|
||||
@Test
|
||||
fun sourceIsNullWhenMirrorFilesMissing() {
|
||||
val result = resolveAudioSourceDetailed(null, 0, { File("/x", "audio-$it.ogg") }) { "signed:$it" }
|
||||
assertNull(result.source)
|
||||
assertTrue(result.reason.contains("null"), "причина должна указывать на отсутствующее зеркало: ${result.reason}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sourceIsNullWhenAudiosEmpty() {
|
||||
val result = resolveAudioSourceDetailed(mirrorFiles(), 0, { File("/x", "audio-$it.ogg") }) { "signed:$it" }
|
||||
assertNull(result.source)
|
||||
assertTrue(result.reason.contains("audios пусто"), "причина должна указывать на пустой список дорожек: ${result.reason}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun usesRequestedAudioIndex() {
|
||||
val dir = audioDir()
|
||||
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
|
||||
val audios = listOf(
|
||||
MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"),
|
||||
MirrorAudio(index = 1, key = "a1", url = "https://s3/audio-1.ogg"),
|
||||
)
|
||||
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 1, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
|
||||
assertEquals(File(dir, "audio-1.ogg").absolutePath, result.source)
|
||||
assertTrue(result.reason.contains("локальный файл"), "выбрана именно дорожка #1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackToFirstAudioWhenIndexMissing() {
|
||||
val dir = audioDir()
|
||||
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
|
||||
val audios = listOf(
|
||||
MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"),
|
||||
MirrorAudio(index = 2, key = "a2", url = "https://s3/audio-2.ogg"),
|
||||
)
|
||||
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 7, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
|
||||
assertEquals(File(dir, "audio-0.ogg").absolutePath, result.source, "нет #7 — берём первую дорожку")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun usesLocalFileWhenDownloaded() {
|
||||
val dir = audioDir()
|
||||
val local = File(dir, "audio-3.ogg").apply { writeBytes(ByteArray(10)) }
|
||||
val audios = listOf(MirrorAudio(index = 3, key = "a3", url = "https://s3/audio-3.ogg"))
|
||||
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 3, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
|
||||
assertEquals(local.absolutePath, result.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamsPresignedUrlWhenNoLocalFile() {
|
||||
val dir = audioDir()
|
||||
val audios = listOf(MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"))
|
||||
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 0, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
|
||||
assertEquals("signed:https://s3/audio-0.ogg", result.source)
|
||||
assertTrue(result.reason.contains("стриминг"), "без локального файла — стриминг: ${result.reason}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import pw.binom.viewmate.core.media.MirrorAudio
|
||||
import pw.binom.viewmate.core.media.MirrorFiles
|
||||
import pw.binom.viewmate.core.media.MirrorVideo
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
@@ -115,4 +118,183 @@ class DownloadManagerTest {
|
||||
assertTrue(result.downloaded.isEmpty())
|
||||
assertTrue(result.progress.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fileKeysFromMirrorCoversVideoAndAllAudios() {
|
||||
val files = MirrorFiles(
|
||||
video = MirrorVideo(key = "v", url = "http://v"),
|
||||
audios = listOf(
|
||||
MirrorAudio(index = 0, key = "a0", url = "http://a0"),
|
||||
MirrorAudio(index = 1, key = "a1", url = "http://a1"),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("video", "audio-0", "audio-1"), fileKeys(files))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isFullyOnDiskRequiresAllFilesNoParts() {
|
||||
val base = newBaseDir()
|
||||
val dir = File(base, "i").apply { mkdirs() }
|
||||
val keys = listOf("video", "audio-0", "audio-1")
|
||||
|
||||
assertTrue(!isFullyOnDisk(dir, keys), "пустой каталог — не полный")
|
||||
|
||||
File(dir, "video.mkv").writeBytes(ByteArray(10))
|
||||
assertTrue(!isFullyOnDisk(dir, keys), "только видео — не полный")
|
||||
|
||||
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
|
||||
assertTrue(!isFullyOnDisk(dir, keys), "не все аудио — не полный")
|
||||
|
||||
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
|
||||
assertTrue(isFullyOnDisk(dir, keys), "все файлы — полный")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isFullyOnDiskRejectsPartFiles() {
|
||||
val base = newBaseDir()
|
||||
val dir = File(base, "i").apply { mkdirs() }
|
||||
File(dir, "video.mkv").writeBytes(ByteArray(10))
|
||||
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
|
||||
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
|
||||
File(dir, "audio-0.ogg.part").writeBytes(ByteArray(2))
|
||||
|
||||
assertTrue(!isFullyOnDisk(dir, listOf("video", "audio-0", "audio-1")), ".part не даёт полного")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun combinedPercentAveragesAcrossAllFiles() {
|
||||
val totals = mapOf("video" to 100L, "audio-0" to 50L, "audio-1" to 50L)
|
||||
val done = mapOf("video" to 100L, "audio-0" to 25L, "audio-1" to 0L)
|
||||
assertEquals(62, combinedPercent(totals, done)) // 125/200
|
||||
}
|
||||
|
||||
@Test
|
||||
fun diskPercentCountsPartsAndFinalFiles() {
|
||||
val base = newBaseDir()
|
||||
val dir = File(base, "i").apply { mkdirs() }
|
||||
File(dir, "video.mkv").writeBytes(ByteArray(100)) // видео полностью
|
||||
File(dir, "audio-0.ogg.part").writeBytes(ByteArray(50)) // аудио докачивается
|
||||
val sizes = mapOf("video" to 100L, "audio-0" to 100L, "audio-1" to 100L)
|
||||
|
||||
assertEquals(50, diskPercent(dir, listOf("video", "audio-0", "audio-1"), sizes)) // 150/300
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filePercentFromPartAndFinal() {
|
||||
val base = newBaseDir()
|
||||
val dir = File(base, "i").apply { mkdirs() }
|
||||
|
||||
// ничего нет — процент посчитать нельзя
|
||||
assertNull(filePercent(dir, "video.mkv", emptyMap()))
|
||||
|
||||
// .part: процент от известного размера дорожки
|
||||
File(dir, "video.mkv.part").writeBytes(ByteArray(50))
|
||||
assertEquals(50, filePercent(dir, "video.mkv", mapOf("video" to 100L)))
|
||||
assertNull(filePercent(dir, "video.mkv", emptyMap()), "без размера дорожки процент не считаем")
|
||||
|
||||
// полный файл — 100% (даже если размера в статусе нет)
|
||||
File(dir, "video.mkv").writeBytes(ByteArray(100))
|
||||
assertEquals(100, filePercent(dir, "video.mkv", emptyMap()))
|
||||
|
||||
// аудио не начато
|
||||
assertNull(filePercent(dir, "audio-0.ogg", mapOf("audio-0" to 100L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scanWithExpectedFilesMarksPartialWithOverallPercent() {
|
||||
val base = newBaseDir()
|
||||
val dir = File(base, "i").apply { mkdirs() }
|
||||
File(dir, "video.mkv").writeBytes(ByteArray(100))
|
||||
File(dir, "audio-0.ogg.part").writeBytes(ByteArray(50))
|
||||
val saved = mapOf(
|
||||
"i" to DownloadStatus(
|
||||
phase = "downloading",
|
||||
expectedFiles = listOf("video", "audio-0", "audio-1"),
|
||||
fileSizes = mapOf("video" to 100L, "audio-0" to 100L, "audio-1" to 100L),
|
||||
),
|
||||
)
|
||||
|
||||
val result = scanDownloads(base, saved)
|
||||
|
||||
assertTrue(result.downloaded.isEmpty())
|
||||
assertEquals(DownloadPhase.DOWNLOADING, result.progress["i"]?.phase)
|
||||
assertEquals(50, result.progress["i"]?.percent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scanWithExpectedFilesMarksFullyDownloadedOnlyWhenAllFiles() {
|
||||
val base = newBaseDir()
|
||||
val dir = File(base, "i").apply { mkdirs() }
|
||||
File(dir, "video.mkv").writeBytes(ByteArray(100))
|
||||
File(dir, "audio-0.ogg").writeBytes(ByteArray(50))
|
||||
val saved = mapOf(
|
||||
"i" to DownloadStatus(expectedFiles = listOf("video", "audio-0", "audio-1")),
|
||||
)
|
||||
|
||||
// нет audio-1 — не полный
|
||||
assertTrue(scanDownloads(base, saved).downloaded.isEmpty())
|
||||
|
||||
File(dir, "audio-1.ogg").writeBytes(ByteArray(50))
|
||||
val result = scanDownloads(base, saved)
|
||||
assertEquals(setOf("i"), result.downloaded)
|
||||
assertTrue(result.progress.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fileKeyForMapsPartNamesToTrackKeys() {
|
||||
assertEquals("video", fileKeyFor("video.mkv.part"))
|
||||
assertEquals("audio-0", fileKeyFor("audio-0.ogg.part"))
|
||||
assertEquals("audio-1", fileKeyFor("audio-1.ogg.part"))
|
||||
assertEquals("video.mkv.part", partFileName("video"))
|
||||
assertEquals("audio-1.ogg.part", partFileName("audio-1"))
|
||||
assertEquals("video.mkv", finalFileName("video"))
|
||||
assertEquals("audio-0.ogg", finalFileName("audio-0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeDownloadStateIsIdempotent() {
|
||||
val statuses = mutableMapOf(
|
||||
"i1" to DownloadStatus(phase = "downloading", percent = 40),
|
||||
"i2" to DownloadStatus(phase = "done", percent = 100),
|
||||
)
|
||||
val downloads = mutableMapOf(
|
||||
"i1" to DownloadProgress("i1", DownloadPhase.DOWNLOADING, 40, "видео"),
|
||||
"i2" to DownloadProgress("i2", DownloadPhase.DONE, 100, "готово"),
|
||||
)
|
||||
val downloadedIds = mutableSetOf("i1", "i2")
|
||||
|
||||
removeDownloadState(statuses, downloads, downloadedIds, "i1")
|
||||
|
||||
assertTrue("i1" !in statuses, "статус удалённого itemId не должен оставаться")
|
||||
assertTrue("i1" !in downloads, "прогресс удалённого itemId не должен оставаться")
|
||||
assertTrue("i1" !in downloadedIds)
|
||||
// чужие элементы не трогаем
|
||||
assertEquals("done", statuses["i2"]?.phase)
|
||||
assertTrue("i2" in downloads)
|
||||
|
||||
// повторный вызов (и даже третий) — идемпотентен, ничего не меняет
|
||||
val snapshot = Triple(statuses.toMap(), downloads.toMap(), downloadedIds.toSet())
|
||||
removeDownloadState(statuses, downloads, downloadedIds, "i1")
|
||||
removeDownloadState(statuses, downloads, downloadedIds, "i1")
|
||||
assertEquals(snapshot, Triple(statuses.toMap(), downloads.toMap(), downloadedIds.toSet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteBumpsGenerationInvalidatingRunningDownload() {
|
||||
val generations = mutableMapOf("i1" to 0L)
|
||||
val running = 0L
|
||||
|
||||
assertTrue(isCurrentGeneration(generations, "i1", running), "до удаления качка актуальна")
|
||||
assertTrue(isCurrentGeneration(generations, "i2", 0L), "нет записи — поколение 0 актуально")
|
||||
|
||||
// delete() увеличивает поколение
|
||||
generations["i1"] = (generations["i1"] ?: 0L) + 1
|
||||
|
||||
assertTrue(
|
||||
!isCurrentGeneration(generations, "i1", running),
|
||||
"старая незавершённая корутина после delete должна «умереть», а не воскресить качку",
|
||||
)
|
||||
assertTrue(isCurrentGeneration(generations, "i1", 1L), "новая качка после delete актуальна")
|
||||
assertTrue(isCurrentGeneration(generations, "i2", 0L), "другие itemId не затронуты")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import pw.binom.viewmate.core.media.MirrorAudio
|
||||
import pw.binom.viewmate.core.media.MirrorFiles
|
||||
import pw.binom.viewmate.core.media.MirrorVideo
|
||||
import pw.binom.viewmate.core.phone.buildDownloadFiles
|
||||
import pw.binom.viewmate.core.phone.buildGlassesFileSpecs
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GlassesFilesTest {
|
||||
|
||||
private fun tempDir(): File = Files.createTempDirectory("glasses-files-test").toFile()
|
||||
|
||||
private fun mirrorFiles() = MirrorFiles(
|
||||
video = MirrorVideo(key = "v", url = "https://s3/mirror/video.mkv"),
|
||||
audios = listOf(
|
||||
MirrorAudio(index = 0, key = "a0", title = "Русский", url = "https://s3/mirror/audio-0.ogg"),
|
||||
MirrorAudio(index = 1, key = "a1", url = "https://s3/mirror/audio-1.ogg"),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun buildDownloadFilesUsesPresignedUrlsAndNames() {
|
||||
val msg = buildDownloadFiles(
|
||||
itemId = "movie-1",
|
||||
title = "Interstellar",
|
||||
files = mirrorFiles(),
|
||||
sizes = emptyMap(),
|
||||
presign = { "$it?sig" },
|
||||
)
|
||||
|
||||
assertEquals("movie-1", msg.itemId)
|
||||
assertEquals("Interstellar", msg.title)
|
||||
assertEquals(3, msg.files.size)
|
||||
assertEquals("video.mkv", msg.files[0].name)
|
||||
assertEquals("https://s3/mirror/video.mkv?sig", msg.files[0].url)
|
||||
assertEquals("audio-0.ogg", msg.files[1].name)
|
||||
assertEquals("audio-1.ogg", msg.files[2].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildDownloadFilesFillsKnownSizes() {
|
||||
val msg = buildDownloadFiles(
|
||||
itemId = "movie-1",
|
||||
title = "T",
|
||||
files = mirrorFiles(),
|
||||
sizes = mapOf("video" to 1_863_946_240L, "audio-0" to 68_157_440L),
|
||||
presign = { it },
|
||||
)
|
||||
|
||||
assertEquals(1_863_946_240L, msg.files[0].sizeBytes)
|
||||
assertEquals(68_157_440L, msg.files[1].sizeBytes)
|
||||
assertEquals(0L, msg.files[2].sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildGlassesFileSpecsPresignsEveryFile() {
|
||||
val specs = buildGlassesFileSpecs(
|
||||
files = mirrorFiles(),
|
||||
sizes = emptyMap(),
|
||||
presign = { "signed-$it" },
|
||||
)
|
||||
assertEquals(3, specs.size)
|
||||
assertTrue(specs.all { it.url.startsWith("signed-") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesFileStateNullIsNone() {
|
||||
assertEquals(FileDownloadState.NONE to 0, glassesFileState(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesFileStateMapsPhases() {
|
||||
val done = DownloadProgress("i", "video.mkv", 100, 10, 10, DownloadPhase.DONE)
|
||||
assertEquals(FileDownloadState.DONE to 100, glassesFileState(done))
|
||||
|
||||
val downloading = DownloadProgress("i", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)
|
||||
assertEquals(FileDownloadState.DOWNLOADING to 45, glassesFileState(downloading))
|
||||
|
||||
val failed = DownloadProgress("i", "audio-0.ogg", 0, 5, 10, DownloadPhase.FAILED)
|
||||
assertEquals(FileDownloadState.FAILED to 0, glassesFileState(failed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesFileStateClampsDownloadingPercent() {
|
||||
val p = DownloadProgress("i", "video.mkv", 150, 150, 100, DownloadPhase.DOWNLOADING)
|
||||
assertEquals(FileDownloadState.DOWNLOADING to 99, glassesFileState(p))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun phoneFileStateFinalExistsIsDone() {
|
||||
val dir = tempDir()
|
||||
val final = File(dir, "video.mkv").apply { writeBytes(ByteArray(10)) }
|
||||
val part = File(dir, "video.mkv.part")
|
||||
|
||||
assertEquals(FileDownloadState.DONE to 100, phoneFileState(final, part))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun phoneFileStatePartIsDownloading() {
|
||||
val dir = tempDir()
|
||||
val final = File(dir, "video.mkv")
|
||||
val part = File(dir, "video.mkv.part").apply { writeBytes(ByteArray(10)) }
|
||||
|
||||
assertEquals(FileDownloadState.DOWNLOADING to 0, phoneFileState(final, part))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun phoneFileStateNoneWhenNothing() {
|
||||
val dir = tempDir()
|
||||
assertEquals(FileDownloadState.NONE to 0, phoneFileState(File(dir, "video.mkv"), File(dir, "video.mkv.part")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesOverallPercentEmptyIsNull() {
|
||||
assertNull(glassesOverallPercent(listOf("video.mkv", "audio-0.ogg"), emptyMap(), emptyMap()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesOverallPercentAllDoneIsHundred() {
|
||||
val names = listOf("video.mkv", "audio-0.ogg")
|
||||
val perFile = mapOf(
|
||||
"video.mkv" to DownloadProgress("i", "video.mkv", 100, 100, 100, DownloadPhase.DONE),
|
||||
"audio-0.ogg" to DownloadProgress("i", "audio-0.ogg", 100, 50, 50, DownloadPhase.DONE),
|
||||
)
|
||||
assertEquals(100, glassesOverallPercent(names, emptyMap(), perFile))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesOverallPercentUsesKnownSizes() {
|
||||
val names = listOf("video.mkv", "audio-0.ogg")
|
||||
val known = mapOf("video.mkv" to 100L, "audio-0.ogg" to 100L)
|
||||
val perFile = mapOf(
|
||||
"video.mkv" to DownloadProgress("i", "video.mkv", 100, 100, 100, DownloadPhase.DONE),
|
||||
"audio-0.ogg" to DownloadProgress("i", "audio-0.ogg", 25, 25, 100, DownloadPhase.DOWNLOADING),
|
||||
)
|
||||
assertEquals(62, glassesOverallPercent(names, known, perFile)) // 125/200
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesOverallPercentFallsBackToStartedTotals() {
|
||||
val names = listOf("video.mkv", "audio-0.ogg")
|
||||
val perFile = mapOf(
|
||||
"video.mkv" to DownloadProgress("i", "video.mkv", 50, 50, 100, DownloadPhase.DOWNLOADING),
|
||||
)
|
||||
assertEquals(50, glassesOverallPercent(names, emptyMap(), perFile))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesLivePercentRejectsEmpty() {
|
||||
assertNull(glassesLivePercent(emptyMap()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesLivePercentAllDoneIsHundred() {
|
||||
val perFile = mapOf(
|
||||
"video.mkv" to DownloadProgress("i", "video.mkv", 100, 100, 100, DownloadPhase.DONE),
|
||||
)
|
||||
assertEquals(100, glassesLivePercent(perFile))
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.phone.PhoneState
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.core.protocol.DownloadProgress
|
||||
import pw.binom.viewmate.core.protocol.Gesture
|
||||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
@@ -112,6 +114,55 @@ class GlassesServerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
|
||||
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
|
||||
}
|
||||
|
||||
awaitTrue { hub.glassesDownloads.value["i1"]?.size == 2 }
|
||||
assertEquals(45, hub.glassesDownloads.value["i1"]?.get("video.mkv")?.percent)
|
||||
assertEquals(DownloadPhase.DONE, hub.glassesDownloads.value["i1"]?.get("audio-0.ogg")?.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
|
||||
val hub = GlassesHub(PhoneState())
|
||||
application { glassesServerModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
send(
|
||||
json.encodeToString(
|
||||
GlassesToHost.serializer(),
|
||||
GlassesStatus(
|
||||
batteryPercent = 60,
|
||||
storageUsedGb = 12.5,
|
||||
storageTotalGb = 32.0,
|
||||
storageTotalBytes = 34_359_738_368,
|
||||
storageFreeBytes = 20_971_520_000,
|
||||
mediaBytes = 1_863_946_240,
|
||||
downloadedItemIds = listOf("i1", "i2"),
|
||||
mediaDir = "/data/glasses/files/viewmate",
|
||||
appVersion = "0.2.0",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
awaitTrue { hub.downloadedItemIds.value.contains("i1") }
|
||||
assertEquals(34_359_738_368L, hub.storageTotalBytes.value)
|
||||
assertEquals(20_971_520_000L, hub.storageFreeBytes.value)
|
||||
assertEquals(1_863_946_240L, hub.mediaBytes.value)
|
||||
assertEquals("/data/glasses/files/viewmate", hub.mediaDir.value)
|
||||
assertEquals("0.2.0", hub.appVersion.value)
|
||||
}
|
||||
|
||||
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (!condition()) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ComponentsTest {
|
||||
|
||||
@Test
|
||||
fun bytesToSizeKilobytes() {
|
||||
assertEquals("512 КБ", bytesToSize(512 * 1024L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bytesToSizeMegabytes() {
|
||||
assertEquals("65 МБ", bytesToSize(65 * 1024L * 1024L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bytesToSizeGigabytes() {
|
||||
assertEquals("1.86 ГБ", bytesToSize(2_000_000_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bytesToSizeUnknownIsDashDash() {
|
||||
assertEquals("--", bytesToSize(null))
|
||||
assertEquals("--", bytesToSize(0L))
|
||||
assertEquals("--", bytesToSize(-1L))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package pw.binom.viewmate.core.phone
|
||||
|
||||
import pw.binom.viewmate.core.media.MirrorFiles
|
||||
import pw.binom.viewmate.core.protocol.DownloadFiles
|
||||
import pw.binom.viewmate.core.protocol.FileSpec
|
||||
|
||||
/** Имя файла для ключа дорожки ("video" → "video.mkv", "audio-0" → "audio-0.ogg"). */
|
||||
fun trackFileName(key: String): String = if (key == "video") "video.mkv" else "$key.ogg"
|
||||
|
||||
/** Ключи дорожек зеркала: video + каждая аудио (из files.audios, а не с диска). */
|
||||
fun trackKeys(files: MirrorFiles): List<String> =
|
||||
listOf("video") + files.audios.map { "audio-${it.index}" }
|
||||
|
||||
/** Собрать список файлов для DownloadFiles на очки: presigned URL, имя файла, размер (если известен). */
|
||||
fun buildGlassesFileSpecs(
|
||||
files: MirrorFiles,
|
||||
sizes: Map<String, Long>,
|
||||
presign: (String) -> String,
|
||||
): List<FileSpec> {
|
||||
val video = FileSpec(
|
||||
url = presign(files.video.url),
|
||||
name = trackFileName("video"),
|
||||
sizeBytes = sizes["video"] ?: 0L,
|
||||
)
|
||||
val audios = files.audios.map { a ->
|
||||
val key = "audio-${a.index}"
|
||||
FileSpec(
|
||||
url = presign(a.url),
|
||||
name = trackFileName(key),
|
||||
sizeBytes = sizes[key] ?: 0L,
|
||||
)
|
||||
}
|
||||
return listOf(video) + audios
|
||||
}
|
||||
|
||||
/** Полное сообщение DownloadFiles для очков. */
|
||||
fun buildDownloadFiles(
|
||||
itemId: String,
|
||||
title: String,
|
||||
files: MirrorFiles,
|
||||
sizes: Map<String, Long>,
|
||||
presign: (String) -> String,
|
||||
): DownloadFiles = DownloadFiles(
|
||||
itemId = itemId,
|
||||
title = title,
|
||||
files = buildGlassesFileSpecs(files, sizes, presign),
|
||||
)
|
||||
@@ -11,6 +11,8 @@ import pw.binom.viewmate.core.media.MirrorStatus
|
||||
import pw.binom.viewmate.core.protocol.AssistantState
|
||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
import pw.binom.viewmate.core.protocol.DeleteFiles
|
||||
import pw.binom.viewmate.core.protocol.DownloadFiles
|
||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import pw.binom.viewmate.core.protocol.SetMode
|
||||
@@ -83,25 +85,58 @@ class PhoneActions(
|
||||
|
||||
// --- включить на очках ---
|
||||
|
||||
/** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */
|
||||
suspend fun watch(itemId: String, audioIndex: Int = 0, presign: (String) -> String = { it }): String {
|
||||
/**
|
||||
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
|
||||
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
|
||||
*/
|
||||
suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = ""): String {
|
||||
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
val files = status?.files
|
||||
if (status == null || status.status != "done" || files == null) {
|
||||
return "зеркало для «${item.Name ?: itemId}» не готово (${status?.status ?: "нет"}). Закажите: mirror $itemId"
|
||||
}
|
||||
if (!localVideoPath.startsWith("file://")) {
|
||||
return "«${item.Name ?: itemId}» не скачан на очки — сначала «Скачать на очки»"
|
||||
}
|
||||
|
||||
val title = item.Name ?: itemId
|
||||
val videoUrl = presign(files.video.url)
|
||||
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = videoUrl, audioIndex = audioIndex))
|
||||
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex))
|
||||
state.mode = GlassesMode.MOVIE
|
||||
state.currentItem = item
|
||||
state.currentAudioIndex = audioIndex
|
||||
state.mirrorStatus = status
|
||||
state.playing = true
|
||||
state.downloaded[itemId] = "очки"
|
||||
return "включил «$title» на очках (озвучка #$audioIndex): $videoUrl"
|
||||
return "включил «$title» на очках из локального файла (озвучка #$audioIndex)"
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачать контент в память очков: DownloadFiles с presigned URL всех файлов
|
||||
* (video.mkv + audio-N.ogg) и известными размерами дорожек [sizes] (key → байт).
|
||||
*/
|
||||
suspend fun downloadToGlasses(
|
||||
itemId: String,
|
||||
sizes: Map<String, Long> = emptyMap(),
|
||||
presign: (String) -> String = { it },
|
||||
): String {
|
||||
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
val files = status?.files
|
||||
if (status == null || status.status != "done" || files == null) {
|
||||
return "зеркало для «${item.Name ?: itemId}» не готово (${status?.status ?: "нет"})"
|
||||
}
|
||||
val title = item.Name ?: itemId
|
||||
val msg = buildDownloadFiles(
|
||||
itemId = itemId,
|
||||
title = title,
|
||||
files = files,
|
||||
sizes = sizes,
|
||||
presign = presign,
|
||||
)
|
||||
sender.send(msg)
|
||||
log("phone", "скачивание на очки: $itemId (${msg.files.size} файлов)")
|
||||
return "загрузка «$title» на очки"
|
||||
}
|
||||
|
||||
// --- зеркала ---
|
||||
@@ -146,11 +181,12 @@ class PhoneActions(
|
||||
return "скачано $itemId → $target (эмуляция, отмечено в стейте)"
|
||||
}
|
||||
|
||||
/** Удалить скачанное (эмуляция). */
|
||||
fun deleteDownloaded(itemId: String): String {
|
||||
/** Удалить скачанное: DeleteFiles на очки (каталог контента) + снять эмуляционную пометку. */
|
||||
suspend fun deleteDownloaded(itemId: String): String {
|
||||
sender.send(DeleteFiles(itemId))
|
||||
val removed = state.downloaded.remove(itemId)
|
||||
return if (removed != null) "удалено скачанное: $itemId ($removed)"
|
||||
else "не было скачано: $itemId"
|
||||
else "не было скачано (эмуляция): $itemId"
|
||||
}
|
||||
|
||||
// --- сессии ---
|
||||
|
||||
@@ -33,11 +33,34 @@ data class MediaCommandMsg(val command: MediaCommand, val seekMs: Long = 0) : Ho
|
||||
data class PlayVideo(
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
/** ЛОКАЛЬНЫЙ путь на очках (file:///...) ИЛИ пустой. Очки не стримят: http(s)-URL запрещены. */
|
||||
val videoUrl: String,
|
||||
val audioIndex: Int = 0,
|
||||
val startPositionMs: Long = 0,
|
||||
) : HostToGlasses
|
||||
|
||||
/** Файл контента для скачивания на очки. */
|
||||
@Serializable
|
||||
data class FileSpec(
|
||||
val url: String,
|
||||
val name: String,
|
||||
val sizeBytes: Long = 0,
|
||||
)
|
||||
|
||||
/** Скачать контент в память очков: каталог filesDir/viewmate/{itemId}/, файлы — по [FileSpec.name]. */
|
||||
@Serializable
|
||||
@SerialName("download_files")
|
||||
data class DownloadFiles(
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
val files: List<FileSpec>,
|
||||
) : HostToGlasses
|
||||
|
||||
/** Удалить каталог контента на очках. */
|
||||
@Serializable
|
||||
@SerialName("delete_files")
|
||||
data class DeleteFiles(val itemId: String) : HostToGlasses
|
||||
|
||||
/** Состояние ассистента (IDLE/LISTENING/THINKING/SPEAKING) + время записи для панели. */
|
||||
@Serializable
|
||||
@SerialName("assistant_state")
|
||||
|
||||
@@ -22,6 +22,39 @@ data class GlassesStatus(
|
||||
val batteryPercent: Int,
|
||||
val storageUsedGb: Double,
|
||||
val storageTotalGb: Double,
|
||||
/** Полный объём встроенной памяти, байты. */
|
||||
val storageTotalBytes: Long = 0,
|
||||
/** Свободно байт. */
|
||||
val storageFreeBytes: Long = 0,
|
||||
/** Сумма скачанного медиа (filesDir/viewmate), байты. */
|
||||
val mediaBytes: Long = 0,
|
||||
/** itemId полностью скачанных на очки контентов. */
|
||||
val downloadedItemIds: List<String> = emptyList(),
|
||||
/** Каталог контента на очках (filesDir/viewmate) — для локального пути file:// в PlayVideo. */
|
||||
val mediaDir: String = "",
|
||||
/** Версия приложения очков (для экрана информации). */
|
||||
val appVersion: String = "",
|
||||
) : GlassesToHost
|
||||
|
||||
/** Фаза скачивания на очки. */
|
||||
@Serializable
|
||||
enum class DownloadPhase {
|
||||
DOWNLOADING,
|
||||
DONE,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
/** Прогресс скачивания одного файла на очки (очки → телефон). */
|
||||
@Serializable
|
||||
@SerialName("download_progress")
|
||||
data class DownloadProgress(
|
||||
val itemId: String,
|
||||
/** Имя файла ("video.mkv", "audio-0.ogg") — по нему телефон размечает строки файлов. */
|
||||
val fileName: String,
|
||||
val percent: Int,
|
||||
val doneBytes: Long,
|
||||
val totalBytes: Long,
|
||||
val phase: DownloadPhase,
|
||||
) : GlassesToHost
|
||||
|
||||
/** Позиция воспроизведения (очки — мастер таймкода, шлют регулярно). */
|
||||
|
||||
@@ -19,6 +19,8 @@ import pw.binom.viewmate.core.media.CreateMirrorRequest
|
||||
import pw.binom.viewmate.core.media.JellyfinClient
|
||||
import pw.binom.viewmate.core.media.MirrorClient
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
import pw.binom.viewmate.core.protocol.DeleteFiles
|
||||
import pw.binom.viewmate.core.protocol.DownloadFiles
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import kotlin.test.Test
|
||||
@@ -156,9 +158,10 @@ class PhoneActionsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun watchSendsPlayVideoWithUrlAndAudioIndex() = runTest {
|
||||
fun watchSendsPlayVideoWithLocalPathAndAudioIndex() = runTest {
|
||||
val sender = SenderStub()
|
||||
val out = actions(simpleJellyfin(), doneMirror(), sender).watch("m1", audioIndex = 1)
|
||||
val out = actions(simpleJellyfin(), doneMirror(), sender)
|
||||
.watch("m1", audioIndex = 1, localVideoPath = "file:///data/media/viewmate/m1/video.mkv")
|
||||
|
||||
assertTrue(out.contains("включил «Интерстеллар»"))
|
||||
assertEquals(1, sender.sent.size)
|
||||
@@ -166,7 +169,7 @@ class PhoneActionsTest {
|
||||
assertIs<PlayVideo>(msg)
|
||||
assertEquals("m1", msg.itemId)
|
||||
assertEquals("Интерстеллар", msg.title)
|
||||
assertEquals("https://s3/media/m1/video.mp4", msg.videoUrl)
|
||||
assertEquals("file:///data/media/viewmate/m1/video.mkv", msg.videoUrl)
|
||||
assertEquals(1, msg.audioIndex)
|
||||
|
||||
assertEquals("m1", sender.phoneState.currentItem?.Id)
|
||||
@@ -175,13 +178,22 @@ class PhoneActionsTest {
|
||||
assertEquals("done", sender.phoneState.mirrorStatus?.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun watchWithoutLocalPathIsRejected() = runTest {
|
||||
val sender = SenderStub()
|
||||
val out = actions(simpleJellyfin(), doneMirror(), sender).watch("m1")
|
||||
|
||||
assertTrue(out.contains("не скачан на очки"))
|
||||
assertTrue(sender.sent.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun watchWhenMirrorNotReadyReturnsHint() = runTest {
|
||||
val sender = SenderStub()
|
||||
val mirrorClient = mirror { request ->
|
||||
respond("", HttpStatusCode.NotFound)
|
||||
}
|
||||
val out = actions(simpleJellyfin(), mirrorClient, sender).watch("m1")
|
||||
val out = actions(simpleJellyfin(), mirrorClient, sender).watch("m1", localVideoPath = "file:///x/m1/video.mkv")
|
||||
|
||||
assertTrue(out.contains("не готово"))
|
||||
assertTrue(sender.sent.isEmpty())
|
||||
@@ -272,7 +284,35 @@ class PhoneActionsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadAndDeleteDownloadedEmulate() {
|
||||
fun downloadToGlassesBuildsAndSendsDownloadFiles() = runTest {
|
||||
val sender = SenderStub()
|
||||
val out = actions(simpleJellyfin(), doneMirror(), sender)
|
||||
.downloadToGlasses("m1", sizes = mapOf("video" to 100L)) { "signed-$it" }
|
||||
|
||||
assertTrue(out.contains("загрузка"))
|
||||
assertEquals(1, sender.sent.size)
|
||||
val msg = sender.sent.single()
|
||||
assertIs<DownloadFiles>(msg)
|
||||
assertEquals("m1", msg.itemId)
|
||||
assertEquals("Интерстеллар", msg.title)
|
||||
assertEquals(listOf("video.mkv", "audio-1.ogg", "audio-2.ogg"), msg.files.map { it.name })
|
||||
assertEquals("signed-https://s3/media/m1/video.mp4", msg.files[0].url)
|
||||
assertEquals(100L, msg.files[0].sizeBytes)
|
||||
assertTrue(msg.files[0].name.endsWith(".mkv"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadToGlassesWhenMirrorNotReadyReturnsHint() = runTest {
|
||||
val sender = SenderStub()
|
||||
val mirrorClient = mirror { request -> respond("", HttpStatusCode.NotFound) }
|
||||
val out = actions(simpleJellyfin(), mirrorClient, sender).downloadToGlasses("m1")
|
||||
|
||||
assertTrue(out.contains("не готово"))
|
||||
assertTrue(sender.sent.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadAndDeleteDownloadedEmulate() = runTest {
|
||||
val sender = SenderStub()
|
||||
val actions = actions(simpleJellyfin(), doneMirror(), sender)
|
||||
|
||||
@@ -288,6 +328,17 @@ class PhoneActionsTest {
|
||||
assertTrue(rm2.contains("не было скачано"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteDownloadedSendsDeleteFilesToGlasses() = runTest {
|
||||
val sender = SenderStub()
|
||||
actions(simpleJellyfin(), doneMirror(), sender).deleteDownloaded("m1")
|
||||
|
||||
assertEquals(1, sender.sent.size)
|
||||
val msg = sender.sent.single()
|
||||
assertIs<DeleteFiles>(msg)
|
||||
assertEquals("m1", msg.itemId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusShowsCurrentState() = runTest {
|
||||
val sender = SenderStub()
|
||||
@@ -298,7 +349,7 @@ class PhoneActionsTest {
|
||||
assertTrue(empty.contains("ничего"))
|
||||
assertTrue(empty.contains("пауза"))
|
||||
|
||||
actions.watch("m1")
|
||||
actions.watch("m1", localVideoPath = "file:///x/m1/video.mkv")
|
||||
val after = actions.status()
|
||||
assertTrue(after.contains("Играет: Интерстеллар (m1)"))
|
||||
assertTrue(after.contains("играет"))
|
||||
|
||||
@@ -37,6 +37,77 @@ class GlassesToHostTest {
|
||||
assertEquals(32.0, decoded.storageTotalGb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesStatusExtendedFieldsRoundTrip() {
|
||||
val msg: GlassesToHost = GlassesStatus(
|
||||
batteryPercent = 60,
|
||||
storageUsedGb = 12.5,
|
||||
storageTotalGb = 32.0,
|
||||
storageTotalBytes = 34_359_738_368,
|
||||
storageFreeBytes = 20_971_520_000,
|
||||
mediaBytes = 1_863_946_240,
|
||||
downloadedItemIds = listOf("i1", "i2"),
|
||||
mediaDir = "/data/user/0/pw.binom.viewmate.glasses/files/viewmate",
|
||||
appVersion = "0.2.0",
|
||||
)
|
||||
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
||||
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
|
||||
assertIs<GlassesStatus>(decoded)
|
||||
assertEquals(60, decoded.batteryPercent)
|
||||
assertEquals(34_359_738_368, decoded.storageTotalBytes)
|
||||
assertEquals(20_971_520_000, decoded.storageFreeBytes)
|
||||
assertEquals(1_863_946_240, decoded.mediaBytes)
|
||||
assertEquals(listOf("i1", "i2"), decoded.downloadedItemIds)
|
||||
assertEquals("/data/user/0/pw.binom.viewmate.glasses/files/viewmate", decoded.mediaDir)
|
||||
assertEquals("0.2.0", decoded.appVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesStatusDefaultsForOldFields() {
|
||||
val msg: GlassesToHost = GlassesStatus(batteryPercent = 70, storageUsedGb = 10.5, storageTotalGb = 32.0)
|
||||
val decoded = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), msg))
|
||||
assertIs<GlassesStatus>(decoded)
|
||||
assertEquals(0, decoded.storageTotalBytes)
|
||||
assertEquals(0, decoded.storageFreeBytes)
|
||||
assertEquals(0, decoded.mediaBytes)
|
||||
assertTrue(decoded.downloadedItemIds.isEmpty())
|
||||
assertEquals("", decoded.mediaDir)
|
||||
assertEquals("", decoded.appVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadProgressRoundTrip() {
|
||||
val msg: GlassesToHost = DownloadProgress(
|
||||
itemId = "movie-1",
|
||||
fileName = "video.mkv",
|
||||
percent = 45,
|
||||
doneBytes = 838_860_800,
|
||||
totalBytes = 1_863_946_240,
|
||||
phase = DownloadPhase.DOWNLOADING,
|
||||
)
|
||||
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
||||
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
|
||||
assertIs<DownloadProgress>(decoded)
|
||||
assertEquals("movie-1", decoded.itemId)
|
||||
assertEquals("video.mkv", decoded.fileName)
|
||||
assertEquals(45, decoded.percent)
|
||||
assertEquals(838_860_800, decoded.doneBytes)
|
||||
assertEquals(1_863_946_240, decoded.totalBytes)
|
||||
assertEquals(DownloadPhase.DOWNLOADING, decoded.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadProgressDoneAndFailedPhases() {
|
||||
val done: GlassesToHost = DownloadProgress("i", "video.mkv", 100, 10, 10, DownloadPhase.DONE)
|
||||
val failed: GlassesToHost = DownloadProgress("i", "audio-0.ogg", 0, 5, 10, DownloadPhase.FAILED)
|
||||
val decodedDone = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), done))
|
||||
val decodedFailed = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), failed))
|
||||
assertIs<DownloadProgress>(decodedDone)
|
||||
assertEquals(DownloadPhase.DONE, decodedDone.phase)
|
||||
assertIs<DownloadProgress>(decodedFailed)
|
||||
assertEquals(DownloadPhase.FAILED, decodedFailed.phase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playbackPositionRoundTrip() {
|
||||
val msg: GlassesToHost = PlaybackPosition(positionMs = 65_432, playing = true)
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class HostToGlassesTest {
|
||||
|
||||
@@ -113,6 +114,57 @@ class HostToGlassesTest {
|
||||
assertEquals("Планы на выходные", decoded.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadFilesRoundTrip() {
|
||||
val msg: HostToGlasses = DownloadFiles(
|
||||
itemId = "movie-1",
|
||||
title = "Interstellar",
|
||||
files = listOf(
|
||||
FileSpec(url = "https://s3/mirror/video.mkv?sig", name = "video.mkv", sizeBytes = 1_863_946_240),
|
||||
FileSpec(url = "https://s3/mirror/audio-0.ogg?sig", name = "audio-0.ogg", sizeBytes = 68_157_440),
|
||||
),
|
||||
)
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||
assertIs<DownloadFiles>(decoded)
|
||||
assertEquals("movie-1", decoded.itemId)
|
||||
assertEquals("Interstellar", decoded.title)
|
||||
assertEquals(2, decoded.files.size)
|
||||
assertEquals("video.mkv", decoded.files[0].name)
|
||||
assertEquals(1_863_946_240, decoded.files[0].sizeBytes)
|
||||
assertTrue(decoded.files[0].url.startsWith("https://s3/mirror/video.mkv"))
|
||||
assertEquals("audio-0.ogg", decoded.files[1].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadFilesDefaultSize() {
|
||||
val msg: HostToGlasses = DownloadFiles(
|
||||
itemId = "i",
|
||||
title = "t",
|
||||
files = listOf(FileSpec(url = "https://s3/f", name = "video.mkv")),
|
||||
)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), json.encodeToString(HostToGlasses.serializer(), msg))
|
||||
assertIs<DownloadFiles>(decoded)
|
||||
assertEquals(0, decoded.files[0].sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteFilesRoundTrip() {
|
||||
val msg: HostToGlasses = DeleteFiles(itemId = "movie-1")
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||
assertIs<DeleteFiles>(decoded)
|
||||
assertEquals("movie-1", decoded.itemId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playVideoEmptyVideoUrlAllowed() {
|
||||
val msg: HostToGlasses = PlayVideo(itemId = "i", title = "t", videoUrl = "")
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), json.encodeToString(HostToGlasses.serializer(), msg))
|
||||
assertIs<PlayVideo>(decoded)
|
||||
assertEquals("", decoded.videoUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discriminatorIsType() {
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), SetMode(GlassesMode.CHAT))
|
||||
|
||||
Reference in New Issue
Block a user