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 Скачивание на телефон (реальное)
|
## TP-6 Скачивание на телефон (реальное)
|
||||||
- Зеркало done → детали → «Скачать на телефон»: прогресс %, файлы video.mkv + audio-{i}.ogg в filesDir, чипы «скачано». Удаление через «Удалить скачанное».
|
- Зеркало 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.coroutines.android)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(project(":lib-core"))
|
implementation(project(":lib-core"))
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
||||||
// WS-клиент очков (OkHttp — Android-движок)
|
// WS-клиент очков (OkHttp — Android-движок)
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
implementation(libs.ktor.client.websockets)
|
implementation(libs.ktor.client.websockets)
|
||||||
|
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,21 @@ class GlassesApp : Application() {
|
|||||||
lateinit var movieController: MovieController
|
lateinit var movieController: MovieController
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Менеджер скачивания контента в память очков (отправку на телефон — через HostConnection). */
|
||||||
|
lateinit var downloadManager: GlassesDownloadManager
|
||||||
|
private set
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
movieController = MovieController(applicationContext)
|
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()
|
hostConnection.start()
|
||||||
log("app", "HostConnection стартует: ${GlassesConfig.HOST_URL}")
|
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
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
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.net.GlassesWsClient
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
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.GlassesStatus
|
||||||
|
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||||
import pw.binom.viewmate.core.protocol.Hello
|
import pw.binom.viewmate.core.protocol.Hello
|
||||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||||
@@ -27,12 +34,16 @@ import pw.binom.viewmate.core.protocol.Welcome
|
|||||||
/**
|
/**
|
||||||
* Обвязка над [GlassesWsClient] для UI очков:
|
* Обвязка над [GlassesWsClient] для UI очков:
|
||||||
* реконнект, Hello при подключении, периодический статус/позиция,
|
* реконнект, Hello при подключении, периодический статус/позиция,
|
||||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером).
|
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
|
||||||
|
* DownloadFiles/DeleteFiles — менеджером скачивания).
|
||||||
*/
|
*/
|
||||||
class HostConnection(
|
class HostConnection(
|
||||||
private val url: String,
|
private val url: String,
|
||||||
private val movieController: MovieController,
|
private val movieController: MovieController,
|
||||||
|
private val downloadManager: GlassesDownloadManager,
|
||||||
|
context: Context,
|
||||||
) {
|
) {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
private val client = GlassesWsClient(url)
|
private val client = GlassesWsClient(url)
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
@@ -53,7 +64,7 @@ class HostConnection(
|
|||||||
_connected.value = true
|
_connected.value = true
|
||||||
_status.value = "подключено: $url"
|
_status.value = "подключено: $url"
|
||||||
addMessage("соединение установлено")
|
addMessage("соединение установлено")
|
||||||
client.send(Hello(appVersion = "0.1.0"))
|
client.send(Hello(appVersion = APP_VERSION))
|
||||||
},
|
},
|
||||||
onDisconnected = {
|
onDisconnected = {
|
||||||
_connected.value = false
|
_connected.value = false
|
||||||
@@ -64,14 +75,44 @@ class HostConnection(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(5_000)
|
delay(5_000)
|
||||||
// статус/позиция — заглушки (реальные батарея/память — позже)
|
client.send(buildStatus())
|
||||||
client.send(GlassesStatus(batteryPercent = 80, storageUsedGb = 10.5, storageTotalGb = 32.0))
|
|
||||||
val (positionMs, playing) = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
val (positionMs, playing) = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||||
client.send(PlaybackPosition(positionMs = positionMs, playing = playing))
|
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) {
|
private suspend fun handleHostMessage(msg: HostToGlasses) {
|
||||||
when (msg) {
|
when (msg) {
|
||||||
is Welcome -> {
|
is Welcome -> {
|
||||||
@@ -91,6 +132,16 @@ class HostConnection(
|
|||||||
movieController.applyMediaCommand(msg.command, msg.seekMs)
|
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 AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
|
||||||
|
|
||||||
is ShowText -> addMessage("ShowText: ${msg.text}")
|
is ShowText -> addMessage("ShowText: ${msg.text}")
|
||||||
@@ -108,4 +159,8 @@ class HostConnection(
|
|||||||
client.close()
|
client.close()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val APP_VERSION = "0.1.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package pw.binom.viewmate.glasses
|
package pw.binom.viewmate.glasses
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import com.google.android.exoplayer2.MediaItem
|
||||||
@@ -10,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import pw.binom.viewmate.core.MediaCommand
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Держатель плеера фильма: создаёт/освобождает SimpleExoPlayer,
|
* Держатель плеера фильма: создаёт/освобождает SimpleExoPlayer,
|
||||||
@@ -30,6 +32,16 @@ class MovieController(private val context: Context) {
|
|||||||
|
|
||||||
fun playVideo(msg: PlayVideo) {
|
fun playVideo(msg: PlayVideo) {
|
||||||
mainHandler.post {
|
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 {
|
val player = _player ?: SimpleExoPlayer.Builder(context).build().also {
|
||||||
_player = it
|
_player = it
|
||||||
log("glasses", "SimpleExoPlayer создан")
|
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.GlassesMode
|
||||||
import pw.binom.viewmate.core.phone.GlassesSender
|
import pw.binom.viewmate.core.phone.GlassesSender
|
||||||
import pw.binom.viewmate.core.phone.PhoneState
|
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.GlassesOff
|
||||||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||||
@@ -96,7 +97,11 @@ open class GlassesHub(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is GlassesStatus -> {
|
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 -> {
|
is PlaybackPosition -> {
|
||||||
|
|||||||
@@ -50,26 +50,55 @@ internal fun decideSync(
|
|||||||
return SyncDecision(seekToMs = seekToMs, playing = playing)
|
return SyncDecision(seekToMs = seekToMs, playing = playing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат выбора аудио-источника: [source] (локальный путь или presigned URL)
|
||||||
|
* либо null с человекочитаемой [reason] — чтобы лог показал точную причину отсутствия звука.
|
||||||
|
*/
|
||||||
|
internal data class AudioSourceResult(
|
||||||
|
val source: String?,
|
||||||
|
val reason: String,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Источник аудио для просмотра на очках: локальный скачанный `audio-N.ogg`
|
* Источник аудио для просмотра на очках: локальный скачанный `audio-N.ogg`
|
||||||
* (если есть — быстрее, без сети), иначе presigned URL дорожки [audioIndex]
|
* (если есть — быстрее, без сети), иначе 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(
|
fun resolveAudioSource(
|
||||||
downloadManager: DownloadManager,
|
downloadManager: DownloadManager,
|
||||||
itemId: String,
|
itemId: String,
|
||||||
mirrorFiles: MirrorFiles?,
|
mirrorFiles: MirrorFiles?,
|
||||||
audioIndex: Int,
|
audioIndex: Int,
|
||||||
): String? {
|
): String? = resolveAudioSourceDetailed(
|
||||||
val files = mirrorFiles ?: return null
|
mirrorFiles = mirrorFiles,
|
||||||
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull() ?: return null
|
audioIndex = audioIndex,
|
||||||
val local = downloadManager.audioFile(itemId, audio.index)
|
localAudioFile = { downloadManager.audioFile(itemId, it) },
|
||||||
return if (local.exists()) {
|
).source
|
||||||
local.absolutePath
|
|
||||||
} else {
|
|
||||||
S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Аудио-плеер телефона, синхронизированный с мастер-таймкодом очков.
|
* Аудио-плеер телефона, синхронизированный с мастер-таймкодом очков.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import pw.binom.viewmate.core.media.MirrorClient
|
import pw.binom.viewmate.core.media.MirrorClient
|
||||||
|
import pw.binom.viewmate.core.media.MirrorFiles
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -47,6 +48,10 @@ data class DownloadStatus(
|
|||||||
val percent: Int = 0,
|
val percent: Int = 0,
|
||||||
val totalBytes: Long = 0,
|
val totalBytes: Long = 0,
|
||||||
val updatedAt: 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 {
|
internal val statusJson: Json = Json {
|
||||||
@@ -82,9 +87,96 @@ internal data class DiskScanResult(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Скан папок filesDir/viewmate/<itemId>: финальный video.mkv → itemId в downloaded
|
* Удалить состояние скачивания [itemId] из всех карт. Идемпотентно:
|
||||||
* (заодно чистим лишние .part); есть только .part → фаза downloading с percent
|
* повторный вызов ничего не меняет (карты уже не содержат itemId).
|
||||||
* из размера .part и totalBytes из статуса.
|
*/
|
||||||
|
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(
|
internal fun scanDownloads(
|
||||||
baseDir: File,
|
baseDir: File,
|
||||||
@@ -96,17 +188,23 @@ internal fun scanDownloads(
|
|||||||
baseDir.listFiles()?.forEach { dir ->
|
baseDir.listFiles()?.forEach { dir ->
|
||||||
if (!dir.isDirectory) return@forEach
|
if (!dir.isDirectory) return@forEach
|
||||||
val itemId = dir.name
|
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 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
|
downloaded += itemId
|
||||||
parts.forEach { it.delete() }
|
parts.forEach { it.delete() }
|
||||||
} else if (biggestPart != null && biggestPart.length() > 0) {
|
} else if (parts.any { it.length() > 0 }) {
|
||||||
val savedStatus = saved[itemId]
|
val percent = if (keys.isNotEmpty()) {
|
||||||
|
diskPercent(dir, keys, savedStatus?.fileSizes.orEmpty())
|
||||||
|
} else {
|
||||||
val total = savedStatus?.totalBytes ?: 0L
|
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() } ?: "видео"
|
val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "видео"
|
||||||
progress[itemId] = DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, stage)
|
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.
|
* Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
|
||||||
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
|
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
|
||||||
* при старте диск сканируется, докачка — только по явному тапу пользователя.
|
* при старте диск сканируется, докачка — только по явному тапу пользователя.
|
||||||
@@ -135,6 +233,13 @@ class DownloadManager(
|
|||||||
private val baseDir = File(context.filesDir, "viewmate")
|
private val baseDir = File(context.filesDir, "viewmate")
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val jobs = ConcurrentHashMap<String, Job>()
|
private val jobs = ConcurrentHashMap<String, Job>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поколение запуска качки itemId. Растёт при [delete]: незавершённая корутина
|
||||||
|
* после удаления видит несовпадение поколений и «умирает», не создавая файлы/статус заново.
|
||||||
|
*/
|
||||||
|
private val generations = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
private val statuses = ConcurrentHashMap<String, DownloadStatus>()
|
private val statuses = ConcurrentHashMap<String, DownloadStatus>()
|
||||||
private val statusStore = DownloadStatusStore(File(baseDir, "status.json"))
|
private val statusStore = DownloadStatusStore(File(baseDir, "status.json"))
|
||||||
private val okHttp = OkHttpClient.Builder()
|
private val okHttp = OkHttpClient.Builder()
|
||||||
@@ -180,6 +285,8 @@ class DownloadManager(
|
|||||||
percent = p.percent,
|
percent = p.percent,
|
||||||
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
|
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
expectedFiles = statuses[itemId]?.expectedFiles ?: emptyList(),
|
||||||
|
fileSizes = statuses[itemId]?.fileSizes ?: emptyMap(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
statusStore.write(statuses)
|
statusStore.write(statuses)
|
||||||
@@ -213,74 +320,122 @@ class DownloadManager(
|
|||||||
/** Видео уже скачано на телефон. */
|
/** Видео уже скачано на телефон. */
|
||||||
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists()
|
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists()
|
||||||
|
|
||||||
/** Процент неполного файла [part] от полного размера (totalBytes из статуса). */
|
/**
|
||||||
fun partPercent(itemId: String, part: File): Int {
|
* Полностью ли скачано зеркало: video.mkv + все аудио из [files] (без .part, >0 байт).
|
||||||
if (!part.exists() || part.length() <= 0) return 0
|
* Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске.
|
||||||
val total = statuses[itemId]?.totalBytes ?: 0L
|
*/
|
||||||
if (total <= 0) return 0
|
fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean {
|
||||||
return ((part.length() * 100) / total).toInt().coerceIn(0, 99)
|
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]).
|
* Начать (или продолжить) загрузку зеркала [itemId]: видео + ВСЕ аудио-дорожки.
|
||||||
* Если уже качается — повторный вызов игнорируется; если файл полный — тоже.
|
* Если уже качается — повторный вызов игнорируется; если всё полностью скачано — тоже.
|
||||||
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-).
|
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-) для каждого файла.
|
||||||
*/
|
*/
|
||||||
fun startDownload(itemId: String, audioIndex: Int) {
|
fun startDownload(itemId: String) {
|
||||||
if (jobs[itemId]?.isActive == true) return
|
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
|
_activeIds.value = _activeIds.value + itemId
|
||||||
try {
|
try {
|
||||||
|
// корутина «устарела»: itemId удалили (delete) — дальше не идём,
|
||||||
|
// чтобы не создать заново каталог/статус/.part после удаления
|
||||||
|
fun stale() = !isCurrentGeneration(generations, itemId, generation)
|
||||||
|
if (stale()) return@launch
|
||||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, resumePercent(itemId), "получаю зеркало"))
|
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, resumePercent(itemId), "получаю зеркало"))
|
||||||
val status = mirror.mirrorByItem(itemId)
|
val status = mirror.mirrorByItem(itemId)
|
||||||
|
if (stale()) return@launch
|
||||||
val files = status?.files
|
val files = status?.files
|
||||||
if (status == null || status.status != "done" || files == null) {
|
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
|
return@launch
|
||||||
}
|
}
|
||||||
|
val keys = fileKeys(files)
|
||||||
val dir = dirFor(itemId)
|
val dir = dirFor(itemId)
|
||||||
|
if (stale()) return@launch
|
||||||
dir.mkdirs()
|
dir.mkdirs()
|
||||||
|
|
||||||
downloadFile(
|
if (isFullyOnDisk(dir, keys)) {
|
||||||
url = S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
|
||||||
part = partVideo(itemId),
|
_downloadedIds.value = _downloadedIds.value + itemId
|
||||||
dest = videoFile(itemId),
|
log("download", "$itemId уже полностью скачан на диск")
|
||||||
onProgress = { percent ->
|
return@launch
|
||||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "видео"))
|
}
|
||||||
},
|
setExpectedFiles(itemId, keys)
|
||||||
onTotal = { total -> setTotalBytes(itemId, total) },
|
|
||||||
)
|
|
||||||
|
|
||||||
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull()
|
val totals = HashMap<String, Long>()
|
||||||
if (audio != null) {
|
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(
|
downloadFile(
|
||||||
url = S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
url = S3Presigner.presignGet(url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||||
part = partAudio(itemId, audio.index),
|
part = File(dir, partFileName(key)),
|
||||||
dest = audioFile(itemId, audio.index),
|
dest = File(dir, finalFileName(key)),
|
||||||
onProgress = { percent ->
|
onTotal = { total ->
|
||||||
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "звук"))
|
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, "готово"))
|
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
|
||||||
_downloadedIds.value = _downloadedIds.value + itemId
|
_downloadedIds.value = _downloadedIds.value + itemId
|
||||||
log("download", "скачано $itemId → $dir")
|
log("download", "скачано $itemId → $dir")
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
// после delete() не воскрешаем статус «отменено» — состояние уже сброшено
|
||||||
|
if (isCurrentGeneration(generations, itemId, generation)) {
|
||||||
update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено"))
|
update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено"))
|
||||||
|
}
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (isCurrentGeneration(generations, itemId, generation)) {
|
||||||
log("download", "ошибка скачивания $itemId: ${e.message}")
|
log("download", "ошибка скачивания $itemId: ${e.message}")
|
||||||
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
|
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
jobs.remove(itemId)
|
// снимаем ТОЛЬКО свою корутину: после cancel/delete в jobs могла лечь новая
|
||||||
|
jobs.remove(itemId, job)
|
||||||
_activeIds.value = _activeIds.value - itemId
|
_activeIds.value = _activeIds.value - itemId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
jobs[itemId] = job
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отменить текущую загрузку (.part остаётся — при следующем тапе докачается). */
|
/** Отменить текущую загрузку (.part остаётся — при следующем тапе докачается). */
|
||||||
@@ -288,32 +443,34 @@ class DownloadManager(
|
|||||||
jobs.remove(itemId)?.cancel()
|
jobs.remove(itemId)?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Удалить скачанные файлы с телефона и сбросить статус. */
|
/** Удалить скачанные файлы с телефона и сбросить статус. Удаление финально. */
|
||||||
fun delete(itemId: String) {
|
fun delete(itemId: String) {
|
||||||
cancel(itemId)
|
cancel(itemId)
|
||||||
|
// инвалидируем незавершённую корутину: даже если она проснётся позже — дальше не пойдёт
|
||||||
|
generations[itemId] = (generations[itemId] ?: 0L) + 1
|
||||||
dirFor(itemId).deleteRecursively()
|
dirFor(itemId).deleteRecursively()
|
||||||
_downloads.value = _downloads.value - itemId
|
val downloads = _downloads.value.toMutableMap()
|
||||||
_downloadedIds.value = _downloadedIds.value - itemId
|
val downloadedIds = _downloadedIds.value.toMutableSet()
|
||||||
statuses.remove(itemId)
|
removeDownloadState(statuses, downloads, downloadedIds, itemId)
|
||||||
|
_downloads.value = downloads
|
||||||
|
_downloadedIds.value = downloadedIds
|
||||||
|
_activeIds.value = _activeIds.value - itemId
|
||||||
persistStatus(force = true)
|
persistStatus(force = true)
|
||||||
|
log("download", "удалено скачанное $itemId (поколение → ${generations[itemId]})")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке). */
|
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке) — общий процент по всем дорожкам. */
|
||||||
private fun resumePercent(itemId: String): Int {
|
private fun resumePercent(itemId: String): Int = overallPercent(itemId)
|
||||||
val total = statuses[itemId]?.totalBytes ?: 0L
|
|
||||||
if (total <= 0) return 0
|
|
||||||
return partPercent(itemId, partVideo(itemId))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
|
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
|
||||||
* по завершении — rename в [dest] (без .part).
|
* по завершении — rename в [dest] (без .part). [onProgress] отдаёт (скачано, всего).
|
||||||
*/
|
*/
|
||||||
private suspend fun downloadFile(
|
private suspend fun downloadFile(
|
||||||
url: String,
|
url: String,
|
||||||
part: File,
|
part: File,
|
||||||
dest: File,
|
dest: File,
|
||||||
onProgress: (Int) -> Unit,
|
onProgress: (done: Long, total: Long) -> Unit,
|
||||||
onTotal: (Long) -> Unit,
|
onTotal: (Long) -> Unit,
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(Dispatchers.IO) {
|
||||||
val existing = if (part.exists()) part.length() else 0L
|
val existing = if (part.exists()) part.length() else 0L
|
||||||
@@ -334,6 +491,8 @@ class DownloadManager(
|
|||||||
onTotal(total)
|
onTotal(total)
|
||||||
|
|
||||||
val input = body.byteStream()
|
val input = body.byteStream()
|
||||||
|
// перед созданием/открытием .part — стоп, если качку отменили/удалили
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
FileOutputStream(part, resumed).use { out ->
|
FileOutputStream(part, resumed).use { out ->
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
var done = from
|
var done = from
|
||||||
@@ -343,13 +502,13 @@ class DownloadManager(
|
|||||||
if (read < 0) break
|
if (read < 0) break
|
||||||
out.write(buffer, 0, read)
|
out.write(buffer, 0, read)
|
||||||
done += read
|
done += read
|
||||||
if (total > 0) {
|
if (total > 0) onProgress(done, total)
|
||||||
val percent = ((done * 100) / total).toInt().coerceIn(0, 100)
|
|
||||||
onProgress(percent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// перед финализацией (.part → конечный файл) — стоп, если качку отменили/удалили,
|
||||||
|
// иначе удаление «воскреснет» переименованным .part
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
if (dest.exists()) dest.delete()
|
if (dest.exists()) dest.delete()
|
||||||
if (!part.renameTo(dest)) {
|
if (!part.renameTo(dest)) {
|
||||||
part.copyTo(dest, overwrite = true)
|
part.copyTo(dest, overwrite = true)
|
||||||
@@ -358,22 +517,49 @@ class DownloadManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setTotalBytes(itemId: String, total: Long) {
|
private fun setFileSize(itemId: String, key: String, size: Long) {
|
||||||
if (total <= 0) return
|
if (size <= 0) return
|
||||||
val prev = statuses[itemId]
|
val prev = statuses[itemId]
|
||||||
if (prev != null && prev.totalBytes > 0) return
|
val sizes = prev?.fileSizes ?: emptyMap()
|
||||||
statuses[itemId] = prev?.copy(totalBytes = total) ?: DownloadStatus(totalBytes = total)
|
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()
|
persistStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun update(itemId: String, progress: DownloadProgress) {
|
private fun update(itemId: String, progress: DownloadProgress) {
|
||||||
_downloads.value = _downloads.value + (itemId to progress)
|
_downloads.value = _downloads.value + (itemId to progress)
|
||||||
|
val prev = statuses[itemId]
|
||||||
statuses[itemId] = DownloadStatus(
|
statuses[itemId] = DownloadStatus(
|
||||||
phase = progress.phase.name.lowercase(),
|
phase = progress.phase.name.lowercase(),
|
||||||
stage = progress.stage,
|
stage = progress.stage,
|
||||||
percent = progress.percent,
|
percent = progress.percent,
|
||||||
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
|
totalBytes = prev?.totalBytes ?: 0L,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
expectedFiles = prev?.expectedFiles ?: emptyList(),
|
||||||
|
fileSizes = prev?.fileSizes ?: emptyMap(),
|
||||||
)
|
)
|
||||||
persistStatus(force = progress.phase != DownloadPhase.DOWNLOADING)
|
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.GlassesMode
|
||||||
import pw.binom.viewmate.core.phone.GlassesSender
|
import pw.binom.viewmate.core.phone.GlassesSender
|
||||||
import pw.binom.viewmate.core.phone.PhoneState
|
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.GlassesOff
|
||||||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||||
@@ -36,7 +37,8 @@ const val GLASSES_WS_PATH = "/ws/glasses"
|
|||||||
/**
|
/**
|
||||||
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
|
||||||
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
|
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
|
||||||
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза).
|
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
|
||||||
|
* DownloadProgress → карта скачивания на очки).
|
||||||
*/
|
*/
|
||||||
class GlassesHub(
|
class GlassesHub(
|
||||||
private val state: PhoneState,
|
private val state: PhoneState,
|
||||||
@@ -58,6 +60,29 @@ class GlassesHub(
|
|||||||
private val _storageTotalGb = MutableStateFlow<Double?>(null)
|
private val _storageTotalGb = MutableStateFlow<Double?>(null)
|
||||||
val storageTotalGb: StateFlow<Double?> = _storageTotalGb.asStateFlow()
|
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)
|
private val _positionMs = MutableStateFlow<Long?>(null)
|
||||||
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
|
val positionMs: StateFlow<Long?> = _positionMs.asStateFlow()
|
||||||
|
|
||||||
@@ -106,7 +131,24 @@ class GlassesHub(
|
|||||||
_batteryPercent.value = msg.batteryPercent
|
_batteryPercent.value = msg.batteryPercent
|
||||||
_storageUsedGb.value = msg.storageUsedGb
|
_storageUsedGb.value = msg.storageUsedGb
|
||||||
_storageTotalGb.value = msg.storageTotalGb
|
_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 -> {
|
is PlaybackPosition -> {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ enum class Screen {
|
|||||||
CATALOG,
|
CATALOG,
|
||||||
DETAILS,
|
DETAILS,
|
||||||
GLASSES,
|
GLASSES,
|
||||||
|
GLASSES_INFO,
|
||||||
CHAT,
|
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)
|
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 / пусто). */
|
/** Заглушка-подложка скрина (loading / error / пусто). */
|
||||||
@Composable
|
@Composable
|
||||||
fun ScreenPlaceholder(text: String, modifier: Modifier = Modifier) {
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.JellyfinItem
|
||||||
import pw.binom.viewmate.core.media.MirrorJob
|
import pw.binom.viewmate.core.media.MirrorJob
|
||||||
import pw.binom.viewmate.core.media.MirrorStatus
|
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.PhoneApp
|
||||||
import pw.binom.viewmate.phone.PhoneConfig
|
import pw.binom.viewmate.phone.PhoneConfig
|
||||||
import pw.binom.viewmate.phone.S3Presigner
|
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.resolveAudioSource
|
||||||
|
import pw.binom.viewmate.phone.resolveAudioSourceDetailed
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
/** Экран 2 — Детали элемента: озвучка, статус конвертации, скачивание, кнопки. */
|
/** Экран 2 — Детали элемента: озвучка, статус конвертации, скачивание, кнопки. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -63,11 +74,10 @@ fun DetailsScreen(
|
|||||||
var refreshKey by remember(item.Id) { mutableStateOf(0) }
|
var refreshKey by remember(item.Id) { mutableStateOf(0) }
|
||||||
var audioIndex by remember(item.Id) { mutableStateOf(app.state.currentAudioIndex) }
|
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 activeIds by app.downloadManager.activeIds.collectAsState()
|
||||||
val downloadedMap by app.state.downloadedFlow.collectAsState()
|
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||||
val localIds by app.downloadManager.downloadedIds.collectAsState()
|
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||||
var audioDownloaded by remember(item.Id) { mutableStateOf(app.downloadManager.audioFiles(item.Id).isNotEmpty()) }
|
val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
|
||||||
|
|
||||||
// статус зеркала + дорожки; пока конвертируется — опрашиваем
|
// статус зеркала + дорожки; пока конвертируется — опрашиваем
|
||||||
LaunchedEffect(item.Id, refreshKey) {
|
LaunchedEffect(item.Id, refreshKey) {
|
||||||
@@ -82,23 +92,29 @@ fun DetailsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// файлы озвучки появляются после завершения загрузки на телефон
|
val dm = app.downloadManager
|
||||||
LaunchedEffect(item.Id, downloadProgress) {
|
|
||||||
audioDownloaded = app.downloadManager.audioFiles(item.Id).isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
val files = mirrorStatus?.files
|
val files = mirrorStatus?.files
|
||||||
val audios = files?.audios.orEmpty()
|
val audios = files?.audios.orEmpty()
|
||||||
val effectiveAudioIndex = if (audios.any { it.index == audioIndex }) audioIndex else audios.firstOrNull()?.index ?: 0
|
val effectiveAudioIndex = if (audios.any { it.index == audioIndex }) audioIndex else audios.firstOrNull()?.index ?: 0
|
||||||
val download = downloadProgress[item.Id]
|
|
||||||
// «качается» только если идёт активное скачивание; восстановленный из статуса .part — не качается
|
// «качается» только если идёт активное скачивание; восстановленный из статуса .part — не качается
|
||||||
val downloading = activeIds.contains(item.Id)
|
val downloading = activeIds.contains(item.Id)
|
||||||
val videoPart = app.downloadManager.partVideo(item.Id)
|
|
||||||
val hasVideoPart = videoPart.length() > 0
|
val videoPart = dm.partVideo(item.Id)
|
||||||
val audioPartFiles = app.downloadManager.audioParts(item.Id)
|
val hasVideoPart = videoPart.isFile && videoPart.length() > 0
|
||||||
|
val audioPartFiles = dm.audioParts(item.Id)
|
||||||
val hasAudioPart = audioPartFiles.any { it.length() > 0 }
|
val hasAudioPart = audioPartFiles.any { it.length() > 0 }
|
||||||
val videoPartPercent = app.downloadManager.partPercent(item.Id, videoPart)
|
val hasAnyPart = hasVideoPart || hasAudioPart
|
||||||
val audioPartPercent = audioPartFiles.maxOfOrNull { app.downloadManager.partPercent(item.Id, it) } ?: 0
|
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) {
|
fun onSelectAudio(index: Int) {
|
||||||
audioIndex = index
|
audioIndex = index
|
||||||
@@ -114,6 +130,86 @@ fun DetailsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onWatch() {
|
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 {
|
scope.launch {
|
||||||
if (app.server.hub.connected.value == 0) {
|
if (app.server.hub.connected.value == 0) {
|
||||||
snackbar.showSnackbar("очки не подключены")
|
snackbar.showSnackbar("очки не подключены")
|
||||||
@@ -124,34 +220,29 @@ fun DetailsScreen(
|
|||||||
snackbar.showSnackbar("телефон ещё не готов")
|
snackbar.showSnackbar("телефон ещё не готов")
|
||||||
return@launch
|
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)
|
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
|
||||||
}
|
}
|
||||||
if (result.contains("включил")) {
|
|
||||||
// звук — на телефоне: запускаем ту же дорожку (локально или стриминг)
|
|
||||||
resolveAudioSource(app.downloadManager, item.Id, files, effectiveAudioIndex)?.let { source ->
|
|
||||||
app.audioSync.play(source, 0L)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
snackbar.showSnackbar(result)
|
snackbar.showSnackbar(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun onDownloadToggle() {
|
fun onDownloadToggle() {
|
||||||
val dm = app.downloadManager
|
|
||||||
when {
|
when {
|
||||||
// файл уже полный — не запускаем повторную качку
|
// всё уже полное — не запускаем повторную качку
|
||||||
dm.isDownloaded(item.Id) -> Unit
|
dm.isFullyDownloaded(item.Id, files) -> Unit
|
||||||
downloading -> dm.cancel(item.Id)
|
downloading -> dm.cancel(item.Id)
|
||||||
else -> dm.startDownload(item.Id, effectiveAudioIndex)
|
else -> dm.startDownload(item.Id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onDeleteDownloaded() {
|
fun onDeleteDownloaded() {
|
||||||
|
scope.launch {
|
||||||
app.downloadManager.delete(item.Id)
|
app.downloadManager.delete(item.Id)
|
||||||
app.actions?.deleteDownloaded(item.Id)
|
app.actions?.deleteDownloaded(item.Id)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onOrderMirror() {
|
fun onOrderMirror() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -275,29 +366,41 @@ fun DetailsScreen(
|
|||||||
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||||
SectionLabel("скачивание на устройства")
|
SectionLabel("скачивание на устройства")
|
||||||
StatusBlock {
|
StatusBlock {
|
||||||
val onGlasses = downloadedMap[item.Id] == "очки"
|
if (keys.isEmpty()) {
|
||||||
val onPhoneVideo = localIds.contains(item.Id) || download?.phase == DownloadPhase.DONE
|
Text("нет дорожек зеркала", color = PhoneMuted, fontSize = 11.sp)
|
||||||
val onPhoneAudio = audioDownloaded || download?.phase == DownloadPhase.DONE
|
} else {
|
||||||
|
val trackFiles: Map<String, Pair<File, File>> = keys.associateWith { key ->
|
||||||
StatusLine("🕶️ Очки — видео") {
|
if (key == "video") dm.videoFile(item.Id) to dm.partVideo(item.Id)
|
||||||
DeviceChip(downloaded = onGlasses, downloading = false, percent = 0)
|
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("📱 Телефон — видео") {
|
keys.forEach { key ->
|
||||||
DeviceChip(
|
val name = finalFileName(key)
|
||||||
downloaded = onPhoneVideo,
|
val (finalFile, partFile) = trackFiles[key] ?: return@forEach
|
||||||
downloading = !onPhoneVideo && hasVideoPart,
|
val phoneRaw = phoneFileState(finalFile, partFile)
|
||||||
percent = download?.percent ?: videoPartPercent,
|
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))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@@ -320,11 +423,37 @@ fun DetailsScreen(
|
|||||||
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = ::onWatch,
|
onClick = ::onWatch,
|
||||||
|
enabled = glassesFully,
|
||||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = PhoneAccent, contentColor = PhoneBackground),
|
colors = ButtonDefaults.buttonColors(containerColor = PhoneAccent, contentColor = PhoneBackground),
|
||||||
shape = RoundedCornerShape(10.dp),
|
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))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@@ -338,9 +467,9 @@ fun DetailsScreen(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
localIds.contains(item.Id) -> "✓ скачано на телефон"
|
fullyDownloaded -> "✓ скачано на телефон"
|
||||||
downloading -> "Отменить"
|
downloading -> "Отменить"
|
||||||
hasVideoPart -> "Продолжить ($videoPartPercent%)"
|
hasAnyPart -> "Продолжить ($overallPartPercent%)"
|
||||||
else -> "Скачать на телефон"
|
else -> "Скачать на телефон"
|
||||||
},
|
},
|
||||||
fontSize = 13.sp,
|
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
|
@Composable
|
||||||
private fun DeviceChip(downloaded: Boolean, downloading: Boolean, percent: Int) {
|
private fun DownloadFileRow(
|
||||||
when {
|
name: String,
|
||||||
downloaded -> StatusChip("✓ скачано", PhoneGreen, PhoneGreenText)
|
sizeLabel: String,
|
||||||
downloading -> StatusChip("↓ качается… $percent%", PhoneYellow, PhoneBackground)
|
phone: Pair<FileDownloadState, Int>,
|
||||||
else -> StatusChip("нет", PhoneSurface, PhoneMuted)
|
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 kotlinx.coroutines.launch
|
||||||
import pw.binom.viewmate.core.GlassesMode
|
import pw.binom.viewmate.core.GlassesMode
|
||||||
import pw.binom.viewmate.core.MediaCommand
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
|
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||||
import pw.binom.viewmate.phone.PhoneApp
|
import pw.binom.viewmate.phone.PhoneApp
|
||||||
|
import pw.binom.viewmate.phone.glassesLivePercent
|
||||||
|
|
||||||
/** Экран 3 — Статус очков + пульт. */
|
/** Экран 3 — Статус очков + пульт. */
|
||||||
@Composable
|
@Composable
|
||||||
fun GlassesScreen() {
|
fun GlassesScreen(
|
||||||
|
onOpenInfo: () -> Unit,
|
||||||
|
) {
|
||||||
val app = PhoneApp.instance
|
val app = PhoneApp.instance
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -54,12 +58,18 @@ fun GlassesScreen() {
|
|||||||
val currentItem by app.state.currentItemFlow.collectAsState()
|
val currentItem by app.state.currentItemFlow.collectAsState()
|
||||||
val playing by app.state.playingFlow.collectAsState()
|
val playing by app.state.playingFlow.collectAsState()
|
||||||
val mode by app.state.modeFlow.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) }
|
var showDownloads by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val actions = app.actions
|
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) {
|
fun sendCommand(cmd: MediaCommand, seekMs: Long = 0) {
|
||||||
val a = actions ?: return
|
val a = actions ?: return
|
||||||
if (connected == 0) return
|
if (connected == 0) return
|
||||||
@@ -98,8 +108,20 @@ fun GlassesScreen() {
|
|||||||
audioPositionMs = audioPos,
|
audioPositionMs = audioPos,
|
||||||
durationMs = currentItem?.RunTimeTicks?.div(10_000),
|
durationMs = currentItem?.RunTimeTicks?.div(10_000),
|
||||||
playing = playing,
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -152,6 +174,7 @@ private fun GlassesStatusCard(
|
|||||||
audioPositionMs: Long,
|
audioPositionMs: Long,
|
||||||
durationMs: Long?,
|
durationMs: Long?,
|
||||||
playing: Boolean,
|
playing: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -159,6 +182,7 @@ private fun GlassesStatusCard(
|
|||||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(PhoneCard)
|
.background(PhoneCard)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
) {
|
) {
|
||||||
// связь
|
// связь
|
||||||
@@ -279,14 +303,15 @@ private fun OcuRow(icon: String, content: @Composable () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Диалог: список скачанного на очках (downloaded → «очки») с удалением. */
|
/** Диалог: список скачанного на очках (реальный статус от очков) с удалением. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun GlassesDownloadsDialog(
|
private fun GlassesDownloadsDialog(
|
||||||
app: PhoneApp,
|
app: PhoneApp,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val downloaded by app.state.downloadedFlow.collectAsState()
|
val scope = rememberCoroutineScope()
|
||||||
val glassesIds = downloaded.filterValues { it == "очки" }.keys.toList()
|
val downloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||||
|
val glassesIds = downloadedIds.toList()
|
||||||
|
|
||||||
var names by remember(glassesIds) { mutableStateOf<Map<String, String>>(emptyMap()) }
|
var names by remember(glassesIds) { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||||
LaunchedEffect(glassesIds) {
|
LaunchedEffect(glassesIds) {
|
||||||
@@ -318,7 +343,9 @@ private fun GlassesDownloadsDialog(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
app.actions?.deleteDownloaded(id)
|
app.actions?.deleteDownloaded(id)
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Text("удалить", color = PhoneRedText, fontSize = 11.sp)
|
Text("удалить", color = PhoneRedText, fontSize = 11.sp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ fun ViewMateApp() {
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = PhoneBackground,
|
containerColor = PhoneBackground,
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (screen != Screen.DETAILS) {
|
if (screen != Screen.DETAILS && screen != Screen.GLASSES_INFO) {
|
||||||
BottomNavBar(current = screen) { screen = it }
|
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()
|
Screen.CHAT -> ChatScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package pw.binom.viewmate.phone
|
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.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class AudioSyncPlayerTest {
|
class AudioSyncPlayerTest {
|
||||||
|
|
||||||
@@ -59,4 +65,70 @@ class AudioSyncPlayerTest {
|
|||||||
assertNull(d.seekToMs)
|
assertNull(d.seekToMs)
|
||||||
assertNull(d.playing)
|
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
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
import kotlinx.serialization.encodeToString
|
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.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
@@ -115,4 +118,183 @@ class DownloadManagerTest {
|
|||||||
assertTrue(result.downloaded.isEmpty())
|
assertTrue(result.downloaded.isEmpty())
|
||||||
assertTrue(result.progress.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 kotlinx.serialization.encodeToString
|
||||||
import pw.binom.viewmate.core.GlassesMode
|
import pw.binom.viewmate.core.GlassesMode
|
||||||
import pw.binom.viewmate.core.phone.PhoneState
|
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.Gesture
|
||||||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
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) {
|
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
|
||||||
val deadline = System.currentTimeMillis() + timeoutMs
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
while (!condition()) {
|
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.AssistantState
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
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.MediaCommandMsg
|
||||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||||
import pw.binom.viewmate.core.protocol.SetMode
|
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 item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
|
||||||
val status = mirror.mirrorByItem(itemId)
|
val status = mirror.mirrorByItem(itemId)
|
||||||
val files = status?.files
|
val files = status?.files
|
||||||
if (status == null || status.status != "done" || files == null) {
|
if (status == null || status.status != "done" || files == null) {
|
||||||
return "зеркало для «${item.Name ?: itemId}» не готово (${status?.status ?: "нет"}). Закажите: mirror $itemId"
|
return "зеркало для «${item.Name ?: itemId}» не готово (${status?.status ?: "нет"}). Закажите: mirror $itemId"
|
||||||
}
|
}
|
||||||
|
if (!localVideoPath.startsWith("file://")) {
|
||||||
|
return "«${item.Name ?: itemId}» не скачан на очки — сначала «Скачать на очки»"
|
||||||
|
}
|
||||||
|
|
||||||
val title = item.Name ?: itemId
|
val title = item.Name ?: itemId
|
||||||
val videoUrl = presign(files.video.url)
|
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex))
|
||||||
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = videoUrl, audioIndex = audioIndex))
|
|
||||||
state.mode = GlassesMode.MOVIE
|
state.mode = GlassesMode.MOVIE
|
||||||
state.currentItem = item
|
state.currentItem = item
|
||||||
state.currentAudioIndex = audioIndex
|
state.currentAudioIndex = audioIndex
|
||||||
state.mirrorStatus = status
|
state.mirrorStatus = status
|
||||||
state.playing = true
|
state.playing = true
|
||||||
state.downloaded[itemId] = "очки"
|
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 (эмуляция, отмечено в стейте)"
|
return "скачано $itemId → $target (эмуляция, отмечено в стейте)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Удалить скачанное (эмуляция). */
|
/** Удалить скачанное: DeleteFiles на очки (каталог контента) + снять эмуляционную пометку. */
|
||||||
fun deleteDownloaded(itemId: String): String {
|
suspend fun deleteDownloaded(itemId: String): String {
|
||||||
|
sender.send(DeleteFiles(itemId))
|
||||||
val removed = state.downloaded.remove(itemId)
|
val removed = state.downloaded.remove(itemId)
|
||||||
return if (removed != null) "удалено скачанное: $itemId ($removed)"
|
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(
|
data class PlayVideo(
|
||||||
val itemId: String,
|
val itemId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
|
/** ЛОКАЛЬНЫЙ путь на очках (file:///...) ИЛИ пустой. Очки не стримят: http(s)-URL запрещены. */
|
||||||
val videoUrl: String,
|
val videoUrl: String,
|
||||||
val audioIndex: Int = 0,
|
val audioIndex: Int = 0,
|
||||||
val startPositionMs: Long = 0,
|
val startPositionMs: Long = 0,
|
||||||
) : HostToGlasses
|
) : 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) + время записи для панели. */
|
/** Состояние ассистента (IDLE/LISTENING/THINKING/SPEAKING) + время записи для панели. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("assistant_state")
|
@SerialName("assistant_state")
|
||||||
|
|||||||
@@ -22,6 +22,39 @@ data class GlassesStatus(
|
|||||||
val batteryPercent: Int,
|
val batteryPercent: Int,
|
||||||
val storageUsedGb: Double,
|
val storageUsedGb: Double,
|
||||||
val storageTotalGb: 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
|
) : 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.JellyfinClient
|
||||||
import pw.binom.viewmate.core.media.MirrorClient
|
import pw.binom.viewmate.core.media.MirrorClient
|
||||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
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.HostToGlasses
|
||||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
@@ -156,9 +158,10 @@ class PhoneActionsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun watchSendsPlayVideoWithUrlAndAudioIndex() = runTest {
|
fun watchSendsPlayVideoWithLocalPathAndAudioIndex() = runTest {
|
||||||
val sender = SenderStub()
|
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("включил «Интерстеллар»"))
|
assertTrue(out.contains("включил «Интерстеллар»"))
|
||||||
assertEquals(1, sender.sent.size)
|
assertEquals(1, sender.sent.size)
|
||||||
@@ -166,7 +169,7 @@ class PhoneActionsTest {
|
|||||||
assertIs<PlayVideo>(msg)
|
assertIs<PlayVideo>(msg)
|
||||||
assertEquals("m1", msg.itemId)
|
assertEquals("m1", msg.itemId)
|
||||||
assertEquals("Интерстеллар", msg.title)
|
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(1, msg.audioIndex)
|
||||||
|
|
||||||
assertEquals("m1", sender.phoneState.currentItem?.Id)
|
assertEquals("m1", sender.phoneState.currentItem?.Id)
|
||||||
@@ -175,13 +178,22 @@ class PhoneActionsTest {
|
|||||||
assertEquals("done", sender.phoneState.mirrorStatus?.status)
|
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
|
@Test
|
||||||
fun watchWhenMirrorNotReadyReturnsHint() = runTest {
|
fun watchWhenMirrorNotReadyReturnsHint() = runTest {
|
||||||
val sender = SenderStub()
|
val sender = SenderStub()
|
||||||
val mirrorClient = mirror { request ->
|
val mirrorClient = mirror { request ->
|
||||||
respond("", HttpStatusCode.NotFound)
|
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(out.contains("не готово"))
|
||||||
assertTrue(sender.sent.isEmpty())
|
assertTrue(sender.sent.isEmpty())
|
||||||
@@ -272,7 +284,35 @@ class PhoneActionsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 sender = SenderStub()
|
||||||
val actions = actions(simpleJellyfin(), doneMirror(), sender)
|
val actions = actions(simpleJellyfin(), doneMirror(), sender)
|
||||||
|
|
||||||
@@ -288,6 +328,17 @@ class PhoneActionsTest {
|
|||||||
assertTrue(rm2.contains("не было скачано"))
|
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
|
@Test
|
||||||
fun statusShowsCurrentState() = runTest {
|
fun statusShowsCurrentState() = runTest {
|
||||||
val sender = SenderStub()
|
val sender = SenderStub()
|
||||||
@@ -298,7 +349,7 @@ class PhoneActionsTest {
|
|||||||
assertTrue(empty.contains("ничего"))
|
assertTrue(empty.contains("ничего"))
|
||||||
assertTrue(empty.contains("пауза"))
|
assertTrue(empty.contains("пауза"))
|
||||||
|
|
||||||
actions.watch("m1")
|
actions.watch("m1", localVideoPath = "file:///x/m1/video.mkv")
|
||||||
val after = actions.status()
|
val after = actions.status()
|
||||||
assertTrue(after.contains("Играет: Интерстеллар (m1)"))
|
assertTrue(after.contains("Играет: Интерстеллар (m1)"))
|
||||||
assertTrue(after.contains("играет"))
|
assertTrue(after.contains("играет"))
|
||||||
|
|||||||
@@ -37,6 +37,77 @@ class GlassesToHostTest {
|
|||||||
assertEquals(32.0, decoded.storageTotalGb)
|
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
|
@Test
|
||||||
fun playbackPositionRoundTrip() {
|
fun playbackPositionRoundTrip() {
|
||||||
val msg: GlassesToHost = PlaybackPosition(positionMs = 65_432, playing = true)
|
val msg: GlassesToHost = PlaybackPosition(positionMs = 65_432, playing = true)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertIs
|
import kotlin.test.assertIs
|
||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class HostToGlassesTest {
|
class HostToGlassesTest {
|
||||||
|
|
||||||
@@ -113,6 +114,57 @@ class HostToGlassesTest {
|
|||||||
assertEquals("Планы на выходные", decoded.title)
|
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
|
@Test
|
||||||
fun discriminatorIsType() {
|
fun discriminatorIsType() {
|
||||||
val text = json.encodeToString(HostToGlasses.serializer(), SetMode(GlassesMode.CHAT))
|
val text = json.encodeToString(HostToGlasses.serializer(), SetMode(GlassesMode.CHAT))
|
||||||
|
|||||||
Reference in New Issue
Block a user