Каталог: массовые действия (конвертировать/скачать/удалить), фильтр «только скачанные» и BackHandler вложенных экранов
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.launch
|
||||
import pw.binom.viewmate.core.media.JellyfinItem
|
||||
import pw.binom.viewmate.core.media.MirrorJob
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.PhoneConfig
|
||||
import pw.binom.viewmate.phone.S3Presigner
|
||||
import pw.binom.viewmate.phone.log
|
||||
|
||||
/** Статусы зеркала, при которых конвертацию заказывать не надо. */
|
||||
private val MIRROR_BUSY_OR_DONE = setOf("done", "queued", "processing")
|
||||
|
||||
/**
|
||||
* Массовые действия страницы сезона/сериала: все эпизоды отправить на конвертацию,
|
||||
* скачать всё (звук → телефон, видео → очки) и удалить локальные файлы (телефон + очки).
|
||||
*
|
||||
* [episodes] — целевой набор эпизодов, [mirrors] — свежие статусы зеркал,
|
||||
* [label] — «сезон»/«сериал» для подписи, [contextName] — название для диалога удаления.
|
||||
*/
|
||||
@Composable
|
||||
fun BulkActionsRow(
|
||||
episodes: List<JellyfinItem>,
|
||||
mirrors: Map<String, MirrorJob>,
|
||||
label: String,
|
||||
contextName: String,
|
||||
onMessage: (String) -> Unit,
|
||||
onStateRefresh: () -> Unit,
|
||||
) {
|
||||
val app = PhoneApp.instance
|
||||
val scope = rememberCoroutineScope()
|
||||
val dm = app.downloadManager
|
||||
val phoneDownloaded by dm.downloadedIds.collectAsState()
|
||||
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
|
||||
val statusOf: (JellyfinItem) -> String? = { mirrors[it.Id]?.status }
|
||||
val needConversion = episodes.filter { statusOf(it) !in MIRROR_BUSY_OR_DONE }
|
||||
val readyToDownload = episodes.filter { statusOf(it) == "done" }
|
||||
/** Если всё уже конвертировано — кнопку «конвертировать» не рисуем. */
|
||||
val convertable = needConversion.isNotEmpty()
|
||||
/** Готовые зеркала, которые ещё не скачаны (телефон/очки); если все скачано — кнопку не рисуем. */
|
||||
val toDownload = readyToDownload.filterNot { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
|
||||
val withLocal = episodes.count { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
|
||||
|
||||
/** Массовая конвертация: заказ по каждому эпизоду без активного/готового зеркала. */
|
||||
fun onConvert() {
|
||||
if (needConversion.isEmpty() || busy) return
|
||||
busy = true
|
||||
scope.launch {
|
||||
var ok = 0
|
||||
var fail = 0
|
||||
for (ep in needConversion) {
|
||||
runCatching { app.mirror.createMirror(ep.Id) }
|
||||
.onSuccess { ok++ }
|
||||
.onFailure {
|
||||
fail++
|
||||
log("bulk", "mirror ${ep.Id}: ${it.message}")
|
||||
}
|
||||
}
|
||||
busy = false
|
||||
onStateRefresh()
|
||||
onMessage(
|
||||
buildString {
|
||||
append("Конвертация: заказано $ok")
|
||||
if (fail > 0) append(", ошибок $fail")
|
||||
append(" ($label)")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Массовое скачивание готовых зеркал: звук на телефон (DownloadManager),
|
||||
* видео на очки (DownloadFiles через actions). Очки не подключены — только звук на телефон.
|
||||
*/
|
||||
fun onDownload() {
|
||||
if (toDownload.isEmpty() || busy) return
|
||||
busy = true
|
||||
scope.launch {
|
||||
val actions = app.actions
|
||||
val glassesOk = app.server.hub.connected.value > 0 && actions != null
|
||||
var videoSent = 0
|
||||
for (ep in toDownload) {
|
||||
dm.startDownload(ep.Id)
|
||||
if (glassesOk) {
|
||||
val result = runCatching {
|
||||
actions.downloadToGlasses(ep.Id, sizes = dm.knownFileSizes(ep.Id)) {
|
||||
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
|
||||
}
|
||||
}
|
||||
if (result.isFailure) log("bulk", "скачивание на очки ${ep.Id}: ${result.exceptionOrNull()?.message}")
|
||||
else videoSent++
|
||||
}
|
||||
}
|
||||
busy = false
|
||||
onStateRefresh()
|
||||
onMessage(
|
||||
buildString {
|
||||
append("Скачивание: звук → телефон, видео → очки ($videoSent) ($label)")
|
||||
if (!glassesOk) append(" [очки не подключены — только телефон]")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Удаление локальных файлов всех эпизодов: диск телефона + каталог контента очков. */
|
||||
fun onDelete() {
|
||||
if (withLocal == 0 || busy) return
|
||||
busy = true
|
||||
scope.launch {
|
||||
var deleted = 0
|
||||
for (ep in episodes) {
|
||||
if (ep.Id !in phoneDownloaded && ep.Id !in glassesDownloadedIds) continue
|
||||
dm.delete(ep.Id)
|
||||
app.actions?.deleteDownloaded(ep.Id)
|
||||
deleted++
|
||||
}
|
||||
busy = false
|
||||
onStateRefresh()
|
||||
onMessage("Удалено локальное: $deleted эпизод(ов) ($label)")
|
||||
}
|
||||
}
|
||||
|
||||
if (convertable || toDownload.isNotEmpty() || withLocal > 0) {
|
||||
Text(
|
||||
text = "все эпизоды $label",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(start = 14.dp, top = 4.dp, end = 14.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (convertable) {
|
||||
OutlinedButton(onClick = ::onConvert, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "конвертировать",
|
||||
fontSize = 10.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (toDownload.isNotEmpty()) {
|
||||
OutlinedButton(onClick = ::onDownload, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "скачать",
|
||||
fontSize = 10.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (withLocal > 0) {
|
||||
OutlinedButton(onClick = { confirmDelete = true }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "удалить локальное ($withLocal)",
|
||||
fontSize = 10.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text("Удалить локальные файлы?") },
|
||||
text = {
|
||||
Text(
|
||||
"«$contextName»: с телефонов и очков удалятся уже скачанные " +
|
||||
"видео/звук по ${withLocal} эпизод(ам). Это действие необратимо.",
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { confirmDelete = false; onDelete() }) { Text("Удалить") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmDelete = false }) { Text("Отмена") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -17,7 +18,10 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -27,6 +31,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -86,6 +91,8 @@ class CatalogState(
|
||||
private set
|
||||
var scrollOffset by mutableStateOf(0)
|
||||
private set
|
||||
// фильтр «только скачанные (и частично)» — общий для списка фильмов и списка эпизодов сезона
|
||||
var onlyDownloaded by mutableStateOf(false)
|
||||
|
||||
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
|
||||
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
|
||||
@@ -233,6 +240,36 @@ fun CatalogScreen(
|
||||
) {
|
||||
val app = PhoneApp.instance
|
||||
val userId by app.userId.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
// Системная кнопка «назад»: выход из вложенного пути (сериал→сезон, папки) на уровень выше
|
||||
BackHandler(enabled = state.canGoBack()) {
|
||||
state.back()
|
||||
}
|
||||
|
||||
// Все эпизоды сериала (для bulk-операций): по каждому сезону — список эпизодов.
|
||||
var seriesEpisodes by remember { mutableStateOf<List<JellyfinItem>?>(null) }
|
||||
val seasonIds = state.items.map { it.Id }
|
||||
LaunchedEffect(state.path, seasonIds, userId) {
|
||||
if (state.path !is CatalogState.Path.Series || userId.isBlank() || state.items.isEmpty()) {
|
||||
seriesEpisodes = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
seriesEpisodes = state.items.flatMap { season ->
|
||||
runCatching { app.jellyfin.items(userId, season.Id, listOf("Episode")) }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
val bulkEpisodes = when (state.path) {
|
||||
is CatalogState.Path.Season -> state.items
|
||||
is CatalogState.Path.Series -> seriesEpisodes ?: emptyList()
|
||||
else -> emptyList()
|
||||
}
|
||||
val bulkLabel = when (state.path) {
|
||||
is CatalogState.Path.Season -> "сезона"
|
||||
is CatalogState.Path.Series -> "сериала"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
// Позиция скролла каталога: сохраняется/восстанавливается между навигациями.
|
||||
val gridState = rememberLazyGridState()
|
||||
@@ -264,12 +301,40 @@ fun CatalogScreen(
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
CatalogTopBar(state = state)
|
||||
if (bulkEpisodes.isNotEmpty()) {
|
||||
BulkActionsRow(
|
||||
episodes = bulkEpisodes,
|
||||
mirrors = state.mirrors,
|
||||
label = bulkLabel,
|
||||
contextName = state.subtitle(),
|
||||
onMessage = { message -> scope.launch { snackbar.showSnackbar(message) } },
|
||||
onStateRefresh = state::refresh,
|
||||
)
|
||||
}
|
||||
// фильтр «только скачанные (и частично)» — остаётся только то, что скачано на каком-то устройстве
|
||||
val visibleItems: List<JellyfinItem> = if (state.onlyDownloaded) {
|
||||
state.items.filter { item ->
|
||||
val id = item.Id
|
||||
downloaded.containsKey(id) ||
|
||||
localDownloaded.contains(id) ||
|
||||
glassesDownloadedIds.contains(id) ||
|
||||
glassesDownloads.containsKey(id)
|
||||
}
|
||||
} else {
|
||||
state.items
|
||||
}
|
||||
if (state.loading && state.items.isEmpty()) {
|
||||
ScreenPlaceholder("загружаю…")
|
||||
} else if (state.error != null && state.items.isEmpty()) {
|
||||
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
|
||||
} else if (state.items.isEmpty()) {
|
||||
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
|
||||
} else if (visibleItems.isEmpty()) {
|
||||
ScreenPlaceholder(
|
||||
when {
|
||||
state.onlyDownloaded && state.items.isNotEmpty() -> "здесь нет скачанных — снимите галочку «только скачанные»"
|
||||
state.query.isNotBlank() -> "ничего не нашлось"
|
||||
else -> "пусто"
|
||||
},
|
||||
)
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
@@ -279,7 +344,7 @@ fun CatalogScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(state.items, key = { it.Id }) { item ->
|
||||
items(visibleItems, key = { it.Id }) { item ->
|
||||
val videoProgress = glassesDownloads[item.Id]?.get("video.mkv")
|
||||
ItemCard(
|
||||
item = item,
|
||||
@@ -317,6 +382,7 @@ fun CatalogScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
SnackbarHost(hostState = snackbar)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +439,21 @@ private fun CatalogTopBar(state: CatalogState) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = state.onlyDownloaded,
|
||||
onCheckedChange = { state.onlyDownloaded = it },
|
||||
modifier = Modifier.padding(end = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = "только скачанные (и частично)",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user