fix(app-phone): звук при watch (диагностика+подстраховка mirrorByItem) + качка не воскресает после удаления

- onWatch: явные логи в каждой точке отказа (watch result / audio source / уже играет / не скачано / mediaDir пуст), свежий mirrorByItem если files не догрузился, звук стартует с позиции очков при уже идущем видео
- resolveAudioSource → resolveAudioSourceDetailed (AudioSourceResult с причиной null)
- DownloadManager: поколения generations — delete() инвалидирует старую корутину (не воскрешает качку), ensureActive перед .part/rename, jobs.remove(itemId, job)
- Тесты 62/0 (в т.ч. поколения — поймали null != 0L)

Инфраструктурный блокер (не код): SeaweedFS на Server3 76.66 в IO-шторме (load ~184, wa 20%, чужой yt-dlp на хосте) — большие GET с s3.binom.pw висят/рвутся с ~14:00; качка на очки FAILED по этой причине.
This commit is contained in:
Hermes Agent
2026-08-19 17:19:53 +03:00
parent fafdddc121
commit 49dac8b33f
29 changed files with 2237 additions and 173 deletions
+30
View File
@@ -271,3 +271,33 @@ APK `pw.binom.viewmate.glasses` установлен.
## TP-6 Скачивание на телефон (реальное) ## 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 ТЕЛЕФОНА
+3
View File
@@ -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))