Каталог: массовые действия (конвертировать/скачать/удалить), фильтр «только скачанные» и 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
|
package pw.binom.viewmate.phone.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.items
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -27,6 +31,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -86,6 +91,8 @@ class CatalogState(
|
|||||||
private set
|
private set
|
||||||
var scrollOffset by mutableStateOf(0)
|
var scrollOffset by mutableStateOf(0)
|
||||||
private set
|
private set
|
||||||
|
// фильтр «только скачанные (и частично)» — общий для списка фильмов и списка эпизодов сезона
|
||||||
|
var onlyDownloaded by mutableStateOf(false)
|
||||||
|
|
||||||
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
|
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
|
||||||
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
|
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
|
||||||
@@ -233,6 +240,36 @@ fun CatalogScreen(
|
|||||||
) {
|
) {
|
||||||
val app = PhoneApp.instance
|
val app = PhoneApp.instance
|
||||||
val userId by app.userId.collectAsState()
|
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()
|
val gridState = rememberLazyGridState()
|
||||||
@@ -264,12 +301,40 @@ fun CatalogScreen(
|
|||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
CatalogTopBar(state = state)
|
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()) {
|
if (state.loading && state.items.isEmpty()) {
|
||||||
ScreenPlaceholder("загружаю…")
|
ScreenPlaceholder("загружаю…")
|
||||||
} else if (state.error != null && state.items.isEmpty()) {
|
} else if (state.error != null && state.items.isEmpty()) {
|
||||||
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
|
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
|
||||||
} else if (state.items.isEmpty()) {
|
} else if (visibleItems.isEmpty()) {
|
||||||
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
|
ScreenPlaceholder(
|
||||||
|
when {
|
||||||
|
state.onlyDownloaded && state.items.isNotEmpty() -> "здесь нет скачанных — снимите галочку «только скачанные»"
|
||||||
|
state.query.isNotBlank() -> "ничего не нашлось"
|
||||||
|
else -> "пусто"
|
||||||
|
},
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
state = gridState,
|
state = gridState,
|
||||||
@@ -279,7 +344,7 @@ fun CatalogScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
items(state.items, key = { it.Id }) { item ->
|
items(visibleItems, key = { it.Id }) { item ->
|
||||||
val videoProgress = glassesDownloads[item.Id]?.get("video.mkv")
|
val videoProgress = glassesDownloads[item.Id]?.get("video.mkv")
|
||||||
ItemCard(
|
ItemCard(
|
||||||
item = item,
|
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