Добавляем экран «Активность»: что сейчас конвертируется и скачивается
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 {
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user