diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/BulkActions.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/BulkActions.kt new file mode 100644 index 0000000..2a8a303 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/BulkActions.kt @@ -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, + mirrors: Map, + 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("Отмена") } + }, + ) + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt index 90b8bf3..04100dd 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt @@ -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>() @@ -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?>(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 = 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, + ) + } } }