Добавляем экран «Активность»: что сейчас конвертируется и скачивается
This commit is contained in:
@@ -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 {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ fun ViewMateApp() {
|
||||
},
|
||||
)
|
||||
|
||||
Screen.ACTIVITY -> ActivityScreen()
|
||||
|
||||
Screen.DETAILS -> {
|
||||
val item = selected
|
||||
if (item != null) {
|
||||
|
||||
Reference in New Issue
Block a user