From 0cfd92a68f6b9dcbb6fe4add399812a96d2f6a6f Mon Sep 17 00:00:00 2001 From: subochev Date: Wed, 2 Sep 2026 22:37:31 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F?= =?UTF-8?q?=D0=B5=D0=BC=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=C2=AB=D0=90?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D1=8C=C2=BB:?= =?UTF-8?q?=20=D1=87=D1=82=D0=BE=20=D1=81=D0=B5=D0=B9=D1=87=D0=B0=D1=81=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=82=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F=20=D0=B8=20=D1=81=D0=BA=D0=B0=D1=87?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../binom/viewmate/phone/ui/ActivityScreen.kt | 211 ++++++++++++++++++ .../pw/binom/viewmate/phone/ui/Components.kt | 6 +- .../pw/binom/viewmate/phone/ui/ViewMateApp.kt | 2 + 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ActivityScreen.kt diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ActivityScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ActivityScreen.kt new file mode 100644 index 0000000..83ebe8a --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ActivityScreen.kt @@ -0,0 +1,211 @@ +package pw.binom.viewmate.phone.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import pw.binom.viewmate.core.media.MirrorJob +import pw.binom.viewmate.core.protocol.DownloadPhase as GlassesDownloadPhase +import pw.binom.viewmate.core.protocol.DownloadProgress as GlassesDownloadProgress +import pw.binom.viewmate.phone.PhoneApp +import pw.binom.viewmate.phone.DownloadPhase as PhoneDownloadPhase +import pw.binom.viewmate.phone.DownloadProgress as PhoneDownloadProgress + +/** Статусы зеркала, которые считаются «конвертируется прямо сейчас». */ +private val CONVERTING_STATUSES = setOf("new", "queued", "processing") + +/** Как часто опрашиваем mirror.list, пока открыт экран (конвертация не шлёт события сама). */ +private const val REFRESH_MS = 15_000L + +/** Строка активности для единого списка в LazyColumn. */ +private sealed interface ActivityRow { + val key: String + + data class Header(val text: String, override val key: String) : ActivityRow + data class Convert(val itemId: String, val title: String, val statusLabel: String, override val key: String) : ActivityRow + data class Download(val itemId: String, val title: String, val kind: String, val percent: Int, override val key: String) : ActivityRow +} + +/** + * Экран «Активность»: всё, что сейчас конвертируется (зеркала) и скачивается + * (звук → телефон, видео → очки). Уровень 1: без серверной доработки — по + * конвертации спиннер (progress на сервере не живой), по скачиванию живой %. + */ +@Composable +fun ActivityScreen() { + val app = PhoneApp.instance + val userId by app.userId.collectAsState() + + // Конвертирующиеся зеркала: опрос mirror.list раз в REFRESH_MS (нет push от сервера). + var converting by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(Unit) { + while (isActive) { + converting = runCatching { + app.mirror.list(limit = 500).filter { it.status in CONVERTING_STATUSES } + }.getOrDefault(converting) + delay(REFRESH_MS) + } + } + + // Живой прогресс скачивания: звук (телефон) — DownloadManager, видео (очки) — хаб. + val phoneDownloads by app.downloadManager.downloads.collectAsState() + val activePhone by app.downloadManager.activeIds.collectAsState() + val glassesDownloads by app.server.hub.glassesDownloads.collectAsState() + + // Настройки/названия предметов по активным itemId (подтягиваем только недостающие). + val activeIds = remember(converting, activePhone, glassesDownloads) { + (converting.mapNotNull { it.itemId } + activePhone + glassesDownloads.keys.filter { it.isNotBlank() }) + .distinct() + } + var titles by remember { mutableStateOf>(emptyMap()) } + LaunchedEffect(activeIds, userId) { + if (userId.isBlank()) return@LaunchedEffect + activeIds.forEach { id -> + if (titles.containsKey(id)) return@forEach + val name = runCatching { app.jellyfin.item(userId, id)?.Name }.getOrNull() + titles = titles + (id to (name?.takeIf { it.isNotBlank() } ?: id)) + } + } + + val rows = buildActivityRows( + converting = converting, + phone = phoneDownloads, + activePhone = activePhone, + glasses = glassesDownloads, + titles = titles, + ) + + if (rows.isEmpty()) { + ScreenPlaceholder("Ничего не конвертируется и не скачивается") + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp), + ) { + items(rows) { row -> + ActivityRowView(row) + } + } + } +} + +private fun buildActivityRows( + converting: List, + phone: Map, + activePhone: Set, + glasses: Map>, + titles: Map, +): List { + val out = ArrayList() + val name = { id: String -> titles[id] ?: id } + + val liveConverting = converting.filter { !it.itemId.isNullOrBlank() } + if (liveConverting.isNotEmpty()) { + out += ActivityRow.Header("Конвертируется", "h-conv") + liveConverting.forEach { job -> + val id = job.itemId.orEmpty() + val label = when (job.status) { + "processing" -> "обрабатывается" + "queued" -> "в очереди" + "new" -> "взято в работу" + else -> job.status ?: "…" + } + out += ActivityRow.Convert(id, name(id), label, "conv-${job.id}") + } + } + + val phoneActive = phone.filterKeys { activePhone.contains(it) } + val glassesActive = glasses.filter { (itemId, files) -> + itemId.isNotBlank() && files.values.any { it.phase == GlassesDownloadPhase.DOWNLOADING } + } + + if (phoneActive.isNotEmpty() || glassesActive.isNotEmpty()) { + out += ActivityRow.Header("Скачивается", "h-dl") + phoneActive.forEach { (id, p) -> + out += ActivityRow.Download(id, name(id), "звук", p.percent, "dl-$id-phone") + } + glassesActive.forEach { (id, files) -> + val video = files["video.mkv"] ?: files.values.firstOrNull() + out += ActivityRow.Download(id, name(id), "видео", video?.percent ?: 0, "dl-$id-glasses") + } + } + return out +} + +@Composable +private fun ActivityRowView(row: ActivityRow) { + when (row) { + is ActivityRow.Header -> SectionLabel( + text = row.text, + modifier = Modifier.padding(top = 10.dp), + ) + + is ActivityRow.Convert -> StatusBlock { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + color = PhoneAccent, + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = row.title, + color = PhoneTextDim, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "зеркало: ${row.statusLabel}", + color = PhoneMuted, + fontSize = 10.sp, + ) + } + } + } + + is ActivityRow.Download -> StatusBlock { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = row.title, + modifier = Modifier.weight(1f), + color = PhoneTextDim, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${row.kind} ${row.percent}%", + color = PhoneMuted, + fontSize = 11.sp, + ) + } + Spacer(Modifier.height(6.dp)) + ProgressBar(percent = row.percent) + } + } + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt index 59c693d..065fc43 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt @@ -37,6 +37,7 @@ import java.util.Locale enum class Screen { CATALOG, DETAILS, + ACTIVITY, GLASSES, GLASSES_INFO, CHAT, @@ -46,6 +47,7 @@ private data class BottomTab(val screen: Screen, val icon: String, val label: St private val bottomTabs = listOf( BottomTab(Screen.CATALOG, "🎬", "Каталог"), + BottomTab(Screen.ACTIVITY, "📶", "Активность"), BottomTab(Screen.GLASSES, "🕶️", "Очки"), BottomTab(Screen.CHAT, "💬", "Чат"), ) @@ -307,7 +309,7 @@ fun ProgressBar(percent: Int, modifier: Modifier = Modifier) { } @Composable -fun SectionLabel(text: String) { +fun SectionLabel(text: String, modifier: Modifier = Modifier) { Text( text = text, color = PhoneMuted, @@ -315,6 +317,6 @@ fun SectionLabel(text: String) { fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(bottom = 6.dp), + modifier = modifier.padding(bottom = 6.dp), ) } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt index 7b23f75..66746ec 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/ViewMateApp.kt @@ -51,6 +51,8 @@ fun ViewMateApp() { }, ) + Screen.ACTIVITY -> ActivityScreen() + Screen.DETAILS -> { val item = selected if (item != null) {