Добавляем экран «Активность»: что сейчас конвертируется и скачивается

This commit is contained in:
2026-09-02 22:37:31 +03:00
parent f656f43e10
commit 0cfd92a68f
3 changed files with 217 additions and 2 deletions
@@ -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<List<MirrorJob>>(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<Map<String, String>>(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<MirrorJob>,
phone: Map<String, PhoneDownloadProgress>,
activePhone: Set<String>,
glasses: Map<String, Map<String, GlassesDownloadProgress>>,
titles: Map<String, String>,
): List<ActivityRow> {
val out = ArrayList<ActivityRow>()
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)
}
}
}
}
@@ -37,6 +37,7 @@ import java.util.Locale
enum class Screen { enum class Screen {
CATALOG, CATALOG,
DETAILS, DETAILS,
ACTIVITY,
GLASSES, GLASSES,
GLASSES_INFO, GLASSES_INFO,
CHAT, CHAT,
@@ -46,6 +47,7 @@ private data class BottomTab(val screen: Screen, val icon: String, val label: St
private val bottomTabs = listOf( private val bottomTabs = listOf(
BottomTab(Screen.CATALOG, "🎬", "Каталог"), BottomTab(Screen.CATALOG, "🎬", "Каталог"),
BottomTab(Screen.ACTIVITY, "📶", "Активность"),
BottomTab(Screen.GLASSES, "🕶️", "Очки"), BottomTab(Screen.GLASSES, "🕶️", "Очки"),
BottomTab(Screen.CHAT, "💬", "Чат"), BottomTab(Screen.CHAT, "💬", "Чат"),
) )
@@ -307,7 +309,7 @@ fun ProgressBar(percent: Int, modifier: Modifier = Modifier) {
} }
@Composable @Composable
fun SectionLabel(text: String) { fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text( Text(
text = text, text = text,
color = PhoneMuted, color = PhoneMuted,
@@ -315,6 +317,6 @@ fun SectionLabel(text: String) {
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
letterSpacing = 0.5.sp, letterSpacing = 0.5.sp,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(bottom = 6.dp), modifier = modifier.padding(bottom = 6.dp),
) )
} }
@@ -51,6 +51,8 @@ fun ViewMateApp() {
}, },
) )
Screen.ACTIVITY -> ActivityScreen()
Screen.DETAILS -> { Screen.DETAILS -> {
val item = selected val item = selected
if (item != null) { if (item != null) {