diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt new file mode 100644 index 0000000..468e8c2 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -0,0 +1,30 @@ +package io.narl.protonstream.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +internal fun EmptyState(title: String, body: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(body, style = MaterialTheme.typography.bodyMedium) + } + } +} + +internal fun formatBytes(bytes: ULong): String { + val value = bytes.toDouble() + return when { + value >= 1024 * 1024 * 1024 -> "%.1f GiB".format(value / (1024 * 1024 * 1024)) + value >= 1024 * 1024 -> "%.1f MiB".format(value / (1024 * 1024)) + value >= 1024 -> "%.1f KiB".format(value / 1024) + else -> "$bytes bytes" + } +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt new file mode 100644 index 0000000..415eabf --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt @@ -0,0 +1,139 @@ +package io.narl.protonstream.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.foundation.lazy.grid.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.work.WorkManager +import io.narl.protonstream.download.DownloadCoordinator +import io.narl.protonstream.download.DownloadStateStore +import io.narl.protonstream.download.RetainedDownload +import io.narl.protonstream.ui.theme.AccentProgress +import io.narl.protonstream.ui.theme.QuietButton +import io.narl.protonstream.ui.theme.TonalButton +import uniffi.pstr_android.SeasonRecord + +@Composable +internal fun DownloadsScreen( + state: AppUiState, + onRemove: (uniffi.pstr_android.OfflineRecord) -> Unit, + onPause: (RetainedDownload) -> Unit, + onResume: (RetainedDownload) -> Unit, + onDeletePartial: (RetainedDownload) -> Unit, + padding: PaddingValues, +) { + val context = LocalContext.current + val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG) + val downloads by work.observeAsState(emptyList()) + // Reading retained metadata on every WorkInfo transition also hydrates + // paused/failed/cancelled entries after WorkManager history is pruned. + val retained = remember(downloads) { DownloadStateStore(context).records() } + // Which show each saved file is from. The offline record knows its episode + // but not its show, and the library is the only thing that does. + val groups = remember(state.offline, state.titles) { + val shows = state.titles.flatMap { title -> + title.seasons.flatMap(SeasonRecord::episodes).map { + "${it.shareId}/${it.linkId}" to (title.canonicalName ?: title.name) + } + }.toMap() + state.offline + .groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" } + .toSortedMap() + } + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), + contentPadding = PaddingValues(bottom = 16.dp), + ) { + item { + Text("Offline downloads", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(16.dp)) + } + if (retained.isEmpty() && state.offline.isEmpty()) { + item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") } + } + // Under the show they belong to, as on desktop: a saved season is + // fourteen rows, and fourteen rows of "Episode 3" name nothing. + groups.forEach { (show, files) -> + item(key = "group/$show") { + Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp)) + Text( + "${files.size} ${if (files.size == 1) "episode" else "episodes"} · " + + formatBytes(files.sumOf { it.size }), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + items(files, key = { "${it.shareId}/${it.linkId}" }) { file -> + val episode = file.episode + Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold) + Text(formatBytes(file.size)) + } + TonalButton(onClick = { onRemove(file) }) { + Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Delete") + } + } + } + } + } + items(retained, key = { "${it.shareId}/${it.linkId}" }) { download -> + val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f + Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Column(Modifier.padding(16.dp)) { + Text(download.label, fontWeight = FontWeight.SemiBold) + Text(download.status.replaceFirstChar { it.uppercase() }) + if (download.total > 0L) { + AccentProgress( + progress = { progress }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + Text("${formatBytes(download.downloaded.toULong())} of ${formatBytes(download.total.toULong())}", style = MaterialTheme.typography.bodySmall) + } + download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp), + ) { + if (download.status == RetainedDownload.STATUS_RUNNING || + download.status == RetainedDownload.STATUS_QUEUED + ) { + TonalButton(onClick = { onPause(download) }) { Text("Pause") } + } else { + TonalButton(onClick = { onResume(download) }) { Text("Resume") } + } + QuietButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") } + } + } + } + } + } +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt new file mode 100644 index 0000000..88b3047 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -0,0 +1,171 @@ +package io.narl.protonstream.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.narl.protonstream.ui.theme.AccentProgress +import uniffi.pstr_android.TitleRecord +import uniffi.pstr_android.EpisodeRecord +import uniffi.pstr_android.SeasonRecord + +/** + * How many shows the continue-watching shelf holds. + * + * A shelf is a shortcut, not a second library: past about this many it is + * quicker to search for the thing than to scroll the shelf looking for it. + */ +private const val CONTINUE_WATCHING_MAX = 12 + +/** One row of the continue-watching shelf: which episode, and where it sits. */ +private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int) + +@Composable +internal fun LibraryScreen( + state: AppUiState, + onSearch: (String) -> Unit, + onResume: (TitleRecord, Int) -> Unit, + onTitle: (TitleRecord) -> Unit, + padding: PaddingValues, +) { + // Most recently played first, one episode per show: a shelf that lists four + // episodes of the same series is a shelf with room for nothing else. + val resumable = remember(state.titles) { + state.titles.mapNotNull { title -> + val playlist = title.seasons.flatMap(SeasonRecord::episodes) + playlist.withIndex() + .filter { it.value.resumeAt != null && !it.value.watched } + .maxByOrNull { it.value.lastPlayed } + ?.let { Resumable(title, it.value, it.index) } + }.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX) + } + Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + OutlinedTextField( + value = state.query, + onValueChange = onSearch, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Search library") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) } + ) + Spacer(Modifier.height(16.dp)) + if (state.loading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + } else if (state.titles.isEmpty()) { + EmptyState("Your library is empty", "Add a Proton Drive public link under Shares, then refresh.") + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(180.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + // Above the grid, and only when there is something in it: what a + // viewer opening the app wants is almost always the thing they + // were part way through, and finding it in an alphabetical grid + // is the long way round. + // + // Inside the grid rather than pinned over it, so the shelf + // scrolls away with the rest instead of holding a fixed slice of + // a phone screen for itself. + if (state.query.isBlank() && resumable.isNotEmpty()) { + item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) { + Column { + Text("Continue watching", style = MaterialTheme.typography.titleMedium) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + items( + resumable, + key = { "${it.episode.shareId}/${it.episode.linkId}" }, + ) { entry -> + Card( + Modifier.width(200.dp) + .clickable { onResume(entry.title, entry.index) }, + ) { + RemoteArtwork( + entry.episode.stillUrl ?: entry.title.backdropUrl, + entry.title.canonicalName ?: entry.title.name, + Modifier.fillMaxWidth().aspectRatio(16f / 9f), + fallback = entry.episode.thumbnailSource, + ) + Column(Modifier.padding(10.dp)) { + Text( + entry.title.canonicalName ?: entry.title.name, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.episode.label, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + entry.episode.progress?.let { progress -> + AccentProgress( + progress = { + progress.toFloat().coerceIn(0f, 1f) + }, + modifier = Modifier.fillMaxWidth() + .padding(top = 8.dp), + ) + } + } + } + } + } + } + } + } + items(state.titles, key = { it.key }) { title -> + Card(Modifier.fillMaxWidth().clickable { onTitle(title) }) { + RemoteArtwork( + title.backdropUrl ?: title.posterUrl, + title.canonicalName ?: title.name, + Modifier.fillMaxWidth().height(210.dp), + fallback = title.thumbnailSource, + ) + Column(Modifier.padding(12.dp)) { + Text(title.name, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text( + listOfNotNull(title.year?.toString(), "${title.episodeCount} episodes").joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index f4060e6..dc4fff6 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -1,71 +1,41 @@ package io.narl.protonstream.ui -import android.content.Intent import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.clickable -import androidx.compose.foundation.border -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -75,66 +45,26 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.window.SecureFlagPolicy import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import androidx.work.WorkManager -import io.narl.protonstream.settings.SettingsStore -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import io.narl.protonstream.download.DownloadCoordinator -import io.narl.protonstream.download.DownloadStateStore -import io.narl.protonstream.download.RetainedDownload -import io.narl.protonstream.native.NativeRuntime -import io.narl.protonstream.ui.theme.AccentButton -import io.narl.protonstream.ui.theme.AccentProgress -import io.narl.protonstream.ui.theme.AppearanceState -import io.narl.protonstream.ui.theme.EdgedButton -import io.narl.protonstream.ui.theme.QuietButton -import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.accentNavigationColors -import io.narl.protonstream.ui.theme.solid -import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.TitleRecord -import uniffi.pstr_android.TrackPreferencesRecord import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.SeasonRecord -import uniffi.pstr_android.MatchRecord -import uniffi.pstr_android.MetadataProvider -import uniffi.pstr_android.FlavorChoice -import uniffi.pstr_android.AppearanceRecord -import uniffi.pstr_android.AccentChoice -import uniffi.pstr_android.PlaybackPrefsRecord import io.narl.protonstream.playback.LibmpvHost import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.PlayerScreen -/** - * How many shows the continue-watching shelf holds. - * - * A shelf is a shortcut, not a second library: past about this many it is - * quicker to search for the thing than to scroll the shelf looking for it. - */ -private const val CONTINUE_WATCHING_MAX = 12 - /** How much room the floating mini transport needs at the foot of a page. */ private val MINI_TRANSPORT_INSET = 88.dp @@ -390,1276 +320,3 @@ private fun MiniTransport( } } } - -/** One row of the continue-watching shelf: which episode, and where it sits. */ -private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int) - -@Composable -private fun LibraryScreen( - state: AppUiState, - onSearch: (String) -> Unit, - onResume: (TitleRecord, Int) -> Unit, - onTitle: (TitleRecord) -> Unit, - padding: PaddingValues, -) { - // Most recently played first, one episode per show: a shelf that lists four - // episodes of the same series is a shelf with room for nothing else. - val resumable = remember(state.titles) { - state.titles.mapNotNull { title -> - val playlist = title.seasons.flatMap(SeasonRecord::episodes) - playlist.withIndex() - .filter { it.value.resumeAt != null && !it.value.watched } - .maxByOrNull { it.value.lastPlayed } - ?.let { Resumable(title, it.value, it.index) } - }.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX) - } - Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { - OutlinedTextField( - value = state.query, - onValueChange = onSearch, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text("Search library") }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) } - ) - Spacer(Modifier.height(16.dp)) - if (state.loading) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - } else if (state.titles.isEmpty()) { - EmptyState("Your library is empty", "Add a Proton Drive public link under Shares, then refresh.") - } else { - LazyVerticalGrid( - columns = GridCells.Adaptive(180.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(bottom = 24.dp), - ) { - // Above the grid, and only when there is something in it: what a - // viewer opening the app wants is almost always the thing they - // were part way through, and finding it in an alphabetical grid - // is the long way round. - // - // Inside the grid rather than pinned over it, so the shelf - // scrolls away with the rest instead of holding a fixed slice of - // a phone screen for itself. - if (state.query.isBlank() && resumable.isNotEmpty()) { - item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) { - Column { - Text("Continue watching", style = MaterialTheme.typography.titleMedium) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(vertical = 12.dp), - ) { - items( - resumable, - key = { "${it.episode.shareId}/${it.episode.linkId}" }, - ) { entry -> - Card( - Modifier.width(200.dp) - .clickable { onResume(entry.title, entry.index) }, - ) { - RemoteArtwork( - entry.episode.stillUrl ?: entry.title.backdropUrl, - entry.title.canonicalName ?: entry.title.name, - Modifier.fillMaxWidth().aspectRatio(16f / 9f), - fallback = entry.episode.thumbnailSource, - ) - Column(Modifier.padding(10.dp)) { - Text( - entry.title.canonicalName ?: entry.title.name, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - entry.episode.label, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - entry.episode.progress?.let { progress -> - AccentProgress( - progress = { - progress.toFloat().coerceIn(0f, 1f) - }, - modifier = Modifier.fillMaxWidth() - .padding(top = 8.dp), - ) - } - } - } - } - } - } - } - } - items(state.titles, key = { it.key }) { title -> - Card(Modifier.fillMaxWidth().clickable { onTitle(title) }) { - RemoteArtwork( - title.backdropUrl ?: title.posterUrl, - title.canonicalName ?: title.name, - Modifier.fillMaxWidth().height(210.dp), - fallback = title.thumbnailSource, - ) - Column(Modifier.padding(12.dp)) { - Text(title.name, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) - Text( - listOfNotNull(title.year?.toString(), "${title.episodeCount} episodes").joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - } - } - } -} - -@Composable -private fun TitleScreen( - title: TitleRecord, - playerReady: Boolean, - onPlay: (List, Int) -> Unit, - onBack: () -> Unit, - onPreferenceError: (Throwable) -> Unit, - onMetadataChanged: () -> Unit, - onSetWatched: (EpisodeRecord, Boolean) -> Unit, - padding: PaddingValues, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var audioLanguage by remember(title.key) { mutableStateOf("") } - var subtitleLanguage by remember(title.key) { mutableStateOf("") } - var subtitlesEnabled by remember(title.key) { mutableStateOf(false) } - var showMatch by remember(title.key) { mutableStateOf(false) } - var expandedSeasons by remember(title.key) { mutableStateOf(setOf(title.seasons.firstOrNull()?.label)) } - - BackHandler(onBack = onBack) - - LaunchedEffect(title.key) { - runCatching { - withContext(Dispatchers.IO) { - NativeRuntime.engine().titleTrackPreferences(title.key) - } - }.onSuccess { preferences -> - // Absent means this title has never been given a choice; the fields - // are then empty and the global preference is what plays. - audioLanguage = preferences?.audioLanguage.orEmpty() - subtitleLanguage = preferences?.subtitleLanguage.orEmpty() - subtitlesEnabled = preferences?.subtitles ?: true - }.onFailure(onPreferenceError) - } - // Display order across seasons: what previous/next and autoplay walk, and - // the same order the desktop client uses. - val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) } - // Where a press on Play lands. Part-watched wins over unwatched, most - // recently played first, which is the order the desktop client resumes in. - val nextUp = remember(playlist) { - val resumable = playlist.withIndex().filter { it.value.resumeAt != null } - resumable.maxByOrNull { it.value.lastPlayed }?.index - ?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 } - ?: 0 - } - // Live download state per episode, so a row can show progress and be paused - // without a trip to the Downloads tab. - val work by WorkManager.getInstance(context) - .getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList()) - val downloads = remember(work) { DownloadStateStore(context).records() } - - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - item { - QuietButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text("Back to library") - } - RemoteArtwork( - title.backdropUrl ?: title.posterUrl, - title.canonicalName ?: title.name, - Modifier.fillMaxWidth().aspectRatio(16f / 9f).padding(top = 12.dp), - fallback = title.thumbnailSource, - ) - Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(top = 12.dp)) - title.originalName?.takeIf { it != title.canonicalName }?.let { - Text(it, style = MaterialTheme.typography.bodyMedium) - } - Text( - listOfNotNull( - (title.metadataYear ?: title.year)?.toString(), - title.rating?.let { "%.1f/10".format(it) }, - title.genres.takeIf { it.isNotEmpty() }?.joinToString(" · "), - ).joinToString(" • "), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 6.dp), - ) - title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) } - // Wrapping, not squeezing: a fixed row hands the last button the - // width the others left over, which is how "More on AniList" ended - // up set one letter per line on a phone. - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), - ) { - // What a press on the poster should play: whatever was left - // part-watched, else the first unwatched episode, else the - // first — the desktop client's `next_up`. - AccentButton(onClick = { onPlay(playlist, nextUp) }, enabled = playerReady && playlist.isNotEmpty()) { - Text( - when { - !playerReady -> "Player loading…" - playlist.getOrNull(nextUp)?.resumeAt != null -> "Resume" - title.watchedCount > 0uL -> "Continue" - else -> "Play" - }, - ) - } - // Only where it would do something different: an unstarted show - // already starts at the beginning. - if (playlist.getOrNull(nextUp)?.resumeAt != null || title.watchedCount > 0uL) { - TonalButton( - onClick = { onPlay(playlist, 0) }, - enabled = playerReady && playlist.isNotEmpty(), - ) { Text("Start over") } - } - // The rest in the same flow, and all the same kind of button: - // one filled primary for the thing the page is for, tonal for - // everything else. A text link among filled buttons was what - // made this block read as three unrelated rows of controls. - TonalButton(onClick = { DownloadCoordinator.enqueue(context, playlist) }) { - Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Download show") - } - TonalButton(onClick = { showMatch = true }) { - Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Change match") - } - // The provider's own page for this title: where a viewer goes to - // check that the thing the app matched is the thing they have. - title.externalUrl?.let { url -> - EdgedButton(onClick = { - runCatching { - context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) - }.onFailure(onPreferenceError) - }) { Text("More on ${title.metadataProvider?.displayName() ?: "the provider"}") } - } - } - // Said plainly, because it changes what a re-match will do: a - // hand-picked match is not overwritten by an automatic pass. - if (title.manualMatch) { - Text( - "Matched by hand", - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(top = 8.dp), - ) - } - Text("${title.watchedCount} of ${title.episodeCount} watched", Modifier.padding(vertical = 12.dp)) - Text("Preferred tracks", style = MaterialTheme.typography.titleMedium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - audioLanguage, - { audioLanguage = it }, - label = { Text("Audio language") }, - modifier = Modifier.weight(1f), - singleLine = true, - ) - OutlinedTextField( - subtitleLanguage, - { subtitleLanguage = it }, - label = { Text("Subtitle language") }, - modifier = Modifier.weight(1f), - singleLine = true, - ) - } - SettingToggle("Enable subtitles", subtitlesEnabled) { subtitlesEnabled = it } - TonalButton(onClick = { - scope.launch { - runCatching { - withContext(Dispatchers.IO) { - NativeRuntime.engine().setTitleTrackPreferences( - title.key, - TrackPreferencesRecord( - audioLanguage.takeIf(String::isNotBlank), - subtitleLanguage.takeIf(String::isNotBlank), - subtitlesEnabled, - ), - ) - } - }.onFailure(onPreferenceError) - } - }) { Text("Save track preferences") } - } - title.seasons.forEach { season -> - val isExpanded = expandedSeasons.contains(season.label) - item(key = "season/${season.label}") { - // A header, not a row with a button parked on the end of it: the - // label and its count are the only things that grow, and the one - // action is an icon of fixed width, so every season header down - // the page starts and ends in the same place. - Row( - Modifier - .fillMaxWidth() - .padding(top = 12.dp) - .clip(MaterialTheme.shapes.medium) - .clickable { - expandedSeasons = if (isExpanded) expandedSeasons - season.label else expandedSeasons + season.label - } - .padding(vertical = 8.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (isExpanded) "Collapse" else "Expand", - ) - Spacer(Modifier.width(10.dp)) - Column(Modifier.weight(1f)) { - Text( - season.label, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - val watched = season.episodes.count(EpisodeRecord::watched) - Text( - "${season.episodes.size} " + - (if (season.episodes.size == 1) "episode" else "episodes") + - if (watched > 0) " · $watched watched" else "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - IconButton(onClick = { DownloadCoordinator.enqueue(context, season.episodes) }) { - Icon( - Icons.Default.Download, - contentDescription = "Download ${season.label}", - ) - } - } - } - if (isExpanded) { - itemsIndexed(season.episodes, key = { _, it -> it.linkId }) { position, episode -> - EpisodeRow( - episode = episode, - numbering = episode.numbering(season.number, position), - download = downloads.firstOrNull { - it.shareId == episode.shareId && it.linkId == episode.linkId - }, - playerReady = playerReady, - onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) }, - onDownload = { DownloadCoordinator.enqueue(context, episode) }, - onPause = { DownloadCoordinator.pause(context, it) }, - onResume = { DownloadCoordinator.resume(context, it) }, - onSetWatched = { onSetWatched(episode, it) }, - ) - } - } - } - } - if (showMatch) { - ChangeMatchDialog( - title = title, - onDismiss = { showMatch = false }, - onChanged = { showMatch = false; onMetadataChanged() }, - onError = onPreferenceError, - ) - } -} - -/** - * `S03E38`, `E38`, or the row's place in the season. - * - * Mirrors `Episode::numbering` in `pstr-core`, plus the desktop client's - * fallback to the season folder's number. The last case is deliberately *not* - * printed as an episode number — nothing in the name numbered this file, and - * `#4` says "fourth in the list" where `E04` would be a claim about the show. - * Some ordinal is required regardless: a season of rows that all read the same - * is a season the viewer cannot navigate at all. - */ -private fun EpisodeRecord.numbering(fallbackSeason: UInt?, position: Int): String { - val season = this.season ?: fallbackSeason - val number = this.number - return when { - season != null && number != null -> "S%02dE%02d".format(season.toInt(), number.toInt()) - number != null -> "E%02d".format(number.toInt()) - else -> "#${position + 1}" - } -} - -/** - * One episode: play it, see where it was left, and drive its download. - * - * The row *is* the play button — the still, the numbering and the name are one - * target, which is what lets the trailing controls be two icons in a fixed - * column rather than a second row of buttons under every episode. Both were - * needed while the row carried a full-width `Play`: it pushed the download and - * watched controls into whatever width was left, and forty of those down a - * season read as forty differently-arranged rows. - */ -@Composable -private fun EpisodeRow( - episode: EpisodeRecord, - numbering: String, - download: RetainedDownload?, - playerReady: Boolean, - onPlay: () -> Unit, - onDownload: () -> Unit, - onPause: (RetainedDownload) -> Unit, - onResume: (RetainedDownload) -> Unit, - onSetWatched: (Boolean) -> Unit, -) { - val running = download?.status == RetainedDownload.STATUS_RUNNING || - download?.status == RetainedDownload.STATUS_QUEUED - // The provider's name for the episode when there is one: "The Cave of - // Skulls" says more than the filename it was parsed out of. The filename - // stays underneath it, and is the whole answer when there is no provider. - val name = episode.providerName ?: episode.detail - val detail = episode.detail.takeIf { it != name } - Card( - Modifier - .fillMaxWidth() - .clickable(enabled = playerReady, onClick = onPlay), - ) { - Column(Modifier.padding(10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - // With metadata off the provider has no still, and Proton's own - // thumbnail is a frame of this very episode. - Box( - Modifier - .width(96.dp) - .aspectRatio(16f / 9f) - .clip(MaterialTheme.shapes.small), - contentAlignment = Alignment.Center, - ) { - RemoteArtwork( - episode.stillUrl, - name, - Modifier.fillMaxSize(), - fallback = episode.thumbnailSource, - ) - // Over the still rather than beside it: the row already - // plays on tap, so this says so without spending a column. - Icon( - Icons.Default.PlayArrow, - contentDescription = null, - tint = Color.White, - modifier = Modifier - .size(30.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.45f)) - .padding(4.dp), - ) - } - Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { - Text( - numbering, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - ) - Text( - name, - fontWeight = FontWeight.SemiBold, - // Seen episodes stay legible but stop competing with the - // one the viewer has not watched yet. - color = if (episode.watched) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.onSurface - }, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 2.dp), - ) - detail?.let { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - // Watched is a judgement the viewer is allowed to overrule: a - // half-watched episode they are done with, or one the 90 % rule - // marked seen because they sat through the credits. - IconButton(onClick = { onSetWatched(!episode.watched) }) { - Icon( - if (episode.watched) Icons.Default.CheckCircle else Icons.Outlined.CheckCircle, - contentDescription = if (episode.watched) "Mark unwatched" else "Mark watched", - tint = if (episode.watched) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline - }, - ) - } - // One slot, always the same width, whatever state the download - // is in — including "there is nothing to say", where an empty - // box keeps the rows above and below it aligned. - Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { - when { - episode.offline -> Icon( - Icons.Default.CheckCircle, - contentDescription = "Saved offline", - tint = MaterialTheme.colorScheme.tertiary, - ) - running -> IconButton(onClick = { onPause(download!!) }) { - Icon(Icons.Default.Pause, contentDescription = "Pause download") - } - download != null -> IconButton(onClick = { onResume(download) }) { - Icon(Icons.Default.Download, contentDescription = "Resume download") - } - else -> IconButton(onClick = onDownload) { - Icon(Icons.Default.Download, contentDescription = "Download") - } - } - } - } - // Where the episode was left, under the row it belongs to. - episode.progress?.takeIf { !episode.watched }?.let { progress -> - AccentProgress( - progress = { progress.toFloat().coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) - } - download?.takeIf { it.total > 0L && !episode.offline }?.let { active -> - AccentProgress( - progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - fill = solid(MaterialTheme.colorScheme.tertiary), - ) - Text( - "${active.status.replaceFirstChar { it.uppercase() }} · " + - "${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun ChangeMatchDialog( - title: TitleRecord, - onDismiss: () -> Unit, - onChanged: () -> Unit, - onError: (Throwable) -> Unit, -) { - var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) } - var searching by remember { mutableStateOf(false) } - var options by remember { mutableStateOf>(emptyList()) } - val scope = rememberCoroutineScope() - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Change match") }, - text = { - Column { - Text("Search the configured metadata provider. Nothing is stored until you choose an entry.") - OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true) - AccentButton(enabled = term.isNotBlank() && !searching, onClick = { - searching = true - scope.launch { - runCatching { - withContext(Dispatchers.IO) { NativeRuntime.engine().searchMatches(title.key, term) } - }.onSuccess { options = it }.onFailure(onError) - searching = false - } - }) { Text(if (searching) "Searching…" else "Search") } - LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) { - items(options, key = { "${it.provider}:${it.remoteId}" }) { option -> - EdgedButton( - onClick = { - scope.launch { - runCatching { - withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) } - }.onSuccess { onChanged() }.onFailure(onError) - } - }, - modifier = Modifier.fillMaxWidth().padding(top = 6.dp), - ) { - Column(Modifier.fillMaxWidth()) { - Text(option.name, fontWeight = FontWeight.SemiBold) - Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · ")) - } - } - } - } - } - }, - confirmButton = { - QuietButton(onClick = { - scope.launch { - runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } } - .onSuccess { onChanged() }.onFailure(onError) - } - }) { Text("Forget match") } - }, - dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } }, - ) -} - -@Composable -private fun SharesScreen( - shares: List, - onAdd: (String, String, String?) -> Unit, - onRepair: (String, String, String?) -> Unit, - onRefresh: (String) -> Unit, - onRemove: (String) -> Unit, - padding: PaddingValues, -) { - var showAdd by remember { mutableStateOf(false) } - var repairing by remember { mutableStateOf(null) } - Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text("Proton Drive public links", style = MaterialTheme.typography.titleLarge) - AccentButton(onClick = { showAdd = true }) { Text("Add share") } - } - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 16.dp)) { - items(shares, key = { it.id }) { share -> - Card(Modifier.fillMaxWidth()) { - Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(share.name, fontWeight = FontWeight.SemiBold) - Text(if (share.hasCustomPassword) "Custom password stored securely" else "Public link") - } - // One share, not the library: a link that has just had - // files added to it should not cost a walk of every - // other one, and a link that has expired should not stop - // the ones that still work from being refreshed. - IconButton(onClick = { onRefresh(share.id) }) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh this share") - } - // Not a remove: a share whose secret has become - // unreadable cannot be removed either, since removal - // deletes a secret the store can no longer touch. - QuietButton(onClick = { repairing = share }) { Text("Re-enter link") } - TonalButton(onClick = { onRemove(share.id) }) { Text("Remove") } - } - } - } - } - } - if (showAdd) AddShareDialog(onDismiss = { showAdd = false }, onAdd = onAdd) - repairing?.let { share -> - RepairShareDialog( - share = share, - onDismiss = { repairing = null }, - onRepair = { url, password -> onRepair(share.id, url, password) }, - ) - } -} - -/** - * Re-enter the link for a share the app can no longer decrypt its secret for. - * - * It has to be the *same* link — a different token is a different share, and - * repointing this one at it would leave every catalog row and every offline file - * describing something that is not there. Rust enforces that; this only says so. - */ -@Composable -private fun RepairShareDialog( - share: ShareRecord, - onDismiss: () -> Unit, - onRepair: (String, String?) -> Unit, -) { - var url by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = onDismiss, - properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), - title = { Text("Re-enter link for ${share.name}") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - "The stored credentials for this share cannot be read — usually after a " + - "screen-lock change or a device restore. Entering the same link again " + - "restores access without losing the library or anything downloaded.", - style = MaterialTheme.typography.bodySmall, - ) - OutlinedTextField( - url, - { url = it }, - label = { Text("Public share URL") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - ) - OutlinedTextField( - password, - { password = it }, - label = { Text("Custom password (optional)") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - ) - } - }, - confirmButton = { - AccentButton( - onClick = { onRepair(url.trim(), password); onDismiss() }, - enabled = url.isNotBlank(), - ) { Text("Restore") } - }, - dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} - -@Composable -private fun AddShareDialog(onDismiss: () -> Unit, onAdd: (String, String, String?) -> Unit) { - var name by remember { mutableStateOf("") } - var url by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = onDismiss, - properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), - title = { Text("Add Proton Drive share") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true) - OutlinedTextField( - url, - { url = it }, - label = { Text("Public share URL") }, - singleLine = true, - // The URL fragment is a secret. Password input disables - // IME learning/suggestions while normal long-press paste - // remains available. - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - ) - OutlinedTextField( - password, - { password = it }, - label = { Text("Custom password (optional)") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - ) - } - }, - confirmButton = { - AccentButton( - onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() }, - enabled = name.isNotBlank() && url.isNotBlank(), - ) { Text("Add") } - }, - dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} - -@Composable -private fun DownloadsScreen( - state: AppUiState, - onRemove: (uniffi.pstr_android.OfflineRecord) -> Unit, - onPause: (RetainedDownload) -> Unit, - onResume: (RetainedDownload) -> Unit, - onDeletePartial: (RetainedDownload) -> Unit, - padding: PaddingValues, -) { - val context = LocalContext.current - val work = WorkManager.getInstance(context).getWorkInfosByTagLiveData(DownloadCoordinator.TAG) - val downloads by work.observeAsState(emptyList()) - // Reading retained metadata on every WorkInfo transition also hydrates - // paused/failed/cancelled entries after WorkManager history is pruned. - val retained = remember(downloads) { DownloadStateStore(context).records() } - // Which show each saved file is from. The offline record knows its episode - // but not its show, and the library is the only thing that does. - val groups = remember(state.offline, state.titles) { - val shows = state.titles.flatMap { title -> - title.seasons.flatMap(SeasonRecord::episodes).map { - "${it.shareId}/${it.linkId}" to (title.canonicalName ?: title.name) - } - }.toMap() - state.offline - .groupBy { shows["${it.shareId}/${it.linkId}"] ?: "Not in the library" } - .toSortedMap() - } - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), - contentPadding = PaddingValues(bottom = 16.dp), - ) { - item { - Text("Offline downloads", style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.height(16.dp)) - } - if (retained.isEmpty() && state.offline.isEmpty()) { - item { EmptyState("No downloads", "Episodes, seasons, and shows saved offline appear here.") } - } - // Under the show they belong to, as on desktop: a saved season is - // fourteen rows, and fourteen rows of "Episode 3" name nothing. - groups.forEach { (show, files) -> - item(key = "group/$show") { - Text(show, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 4.dp)) - Text( - "${files.size} ${if (files.size == 1) "episode" else "episodes"} · " + - formatBytes(files.sumOf { it.size }), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(bottom = 8.dp), - ) - } - items(files, key = { "${it.shareId}/${it.linkId}" }) { file -> - val episode = file.episode - Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { - Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(episode?.label ?: file.linkId, fontWeight = FontWeight.SemiBold) - Text(formatBytes(file.size)) - } - TonalButton(onClick = { onRemove(file) }) { - Icon(Icons.Default.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Delete") - } - } - } - } - } - items(retained, key = { "${it.shareId}/${it.linkId}" }) { download -> - val progress = if (download.total > 0L) download.downloaded.toFloat() / download.total else 0f - Card(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { - Column(Modifier.padding(16.dp)) { - Text(download.label, fontWeight = FontWeight.SemiBold) - Text(download.status.replaceFirstChar { it.uppercase() }) - if (download.total > 0L) { - AccentProgress( - progress = { progress }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) - Text("${formatBytes(download.downloaded.toULong())} of ${formatBytes(download.total.toULong())}", style = MaterialTheme.typography.bodySmall) - } - download.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(top = 8.dp), - ) { - if (download.status == RetainedDownload.STATUS_RUNNING || - download.status == RetainedDownload.STATUS_QUEUED - ) { - TonalButton(onClick = { onPause(download) }) { Text("Pause") } - } else { - TonalButton(onClick = { onResume(download) }) { Text("Resume") } - } - QuietButton(onClick = { onDeletePartial(download) }) { Text("Delete partial") } - } - } - } - } - } -} - -@Composable -private fun SettingsScreen( - state: AppUiState, - onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit, - onMatchAgain: () -> Unit, - onClearCache: () -> Unit, - onRemoveAllOffline: () -> Unit, - padding: PaddingValues, -) { - val context = LocalContext.current - val settings = remember { SettingsStore(context) } - var wifiOnly by remember { mutableStateOf(settings.wifiOnly) } - var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) } - // Playback preferences are the shared store's, not this app's: they are the - // same file the desktop client reads, so a language chosen on one is the - // language the other starts in. - var prefs by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - fun update(change: (PlaybackPrefsRecord) -> PlaybackPrefsRecord) { - val next = change(prefs ?: return) - prefs = next - scope.launch(Dispatchers.IO) { - runCatching { NativeRuntime.engine().setPlaybackPrefs(next) } - } - } - LaunchedEffect(Unit) { - runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } } - .onSuccess { prefs = it } - } - var confirmDelete by remember { mutableStateOf(false) } - var showMetadata by remember { mutableStateOf(false) } - var legalDocument by remember { mutableStateOf(null) } - Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState())) { - Text("Settings", style = MaterialTheme.typography.titleLarge) - SettingToggle("Download on Wi-Fi only", wifiOnly) { - wifiOnly = it - settings.wifiOnly = it - // Constraints are baked in at enqueue time, so a queue that already - // exists keeps the policy it was queued under until it is re-issued. - DownloadCoordinator.applyNetworkPolicy(context) - } - HorizontalDivider() - SettingToggle("Continue audio in the background", backgroundAudio) { - backgroundAudio = it - settings.backgroundAudio = it - } - Text( - "When disabled, leaving playback stops the player instead of keeping a media notification active.", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(bottom = 14.dp), - ) - HorizontalDivider() - Text("Playback", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) - prefs?.let { current -> - SettingToggle("Play the next episode automatically", current.autoplayNext) { on -> - update { it.copy(autoplayNext = on) } - } - SettingToggle("Skip openings and endings automatically", current.autoSkip) { on -> - update { it.copy(autoSkip = on) } - } - Text( - "Openings and endings are read from the chapters a release was muxed with. " + - "With this off you get a Skip button instead, which is the safer default: " + - "a mis-named chapter then costs a tap rather than a scene.", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(bottom = 14.dp), - ) - SettingToggle("Show subtitles", current.subtitles) { on -> - update { it.copy(subtitles = on) } - } - // Language tags, not a picker: which languages exist is a property - // of each file, and a list built from one episode is wrong for the - // next. A show that has been given its own choice keeps it. - LanguageField("Preferred audio language", current.audioLanguage) { tag -> - update { it.copy(audioLanguage = tag) } - } - LanguageField("Preferred subtitle language", current.subtitleLanguage) { tag -> - update { it.copy(subtitleLanguage = tag) } - } - Text( - "Three-letter tags as they appear in the file — \"jpn\", \"eng\". Used for every " + - "title that has not been given a track choice of its own.", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(bottom = 14.dp), - ) - } - HorizontalDivider() - Text("Appearance", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) - AppearancePicker() - HorizontalDivider() - Text("Metadata enrichment", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) - Text( - if (state.metadataSettings.enabled) "On · ${state.metadataSettings.provider.displayName()}" - else "Off (privacy default)", - ) - Text( - "Enabling this sends library title names to the selected third-party provider over HTTPS.", - style = MaterialTheme.typography.bodySmall, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 10.dp)) { - TonalButton(onClick = { showMetadata = true }) { Text("Configure metadata") } - // Every title looked up again, matched ones included: the way out of - // a library the provider answered wrong, which otherwise stays wrong - // for as long as the match is remembered. - TonalButton( - onClick = { onMatchAgain() }, - enabled = state.metadataSettings.enabled && !state.refreshing, - ) { Text("Match everything again") } - } - HorizontalDivider() - Text("Storage", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) - Text("Offline media is encrypted at rest by Android and kept in app-private storage.") - Text( - "${state.storage.offlineCount} episodes offline · ${formatBytes(state.storage.offlineBytes)}", - modifier = Modifier.padding(top = 8.dp), - ) - if (state.storage.partialBytes > 0uL) { - Text( - "Unfinished downloads · ${formatBytes(state.storage.partialBytes)}", - style = MaterialTheme.typography.bodySmall, - ) - } - Text( - "Streaming cache · ${formatBytes(state.storage.cacheBytes)}", - style = MaterialTheme.typography.bodySmall, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 10.dp)) { - // The cache is rebuildable, so it goes without asking. Offline - // episodes are a choice the viewer made, so that one asks. - TonalButton(onClick = onClearCache) { Text("Clear cache") } - TonalButton( - onClick = { confirmDelete = true }, - enabled = state.storage.offlineCount > 0uL, - ) { Text("Delete all offline") } - } - Text("proton-stream Android · GPL-3.0-or-later", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 24.dp)) - Text( - "This program comes with absolutely no warranty. You may redistribute it under the GNU GPL.", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 8.dp), - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) { - TonalButton(onClick = { - legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt") - }) { Text("View license") } - TonalButton(onClick = { - legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md") - }) { Text("View notices") } - } - } - if (confirmDelete) { - AlertDialog( - onDismissRequest = { confirmDelete = false }, - title = { Text("Delete all offline episodes?") }, - text = { - Text( - "${state.storage.offlineCount} episodes (${formatBytes(state.storage.offlineBytes)}) " + - "will be removed from this device. Watch history is kept, and anything " + - "deleted can be downloaded again.", - ) - }, - confirmButton = { - AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") } - }, - dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, - ) - } - legalDocument?.let { document -> - LegalDocumentDialog(document, onDismiss = { legalDocument = null }) - } - if (showMetadata) { - MetadataSettingsDialog( - current = state.metadataSettings, - onDismiss = { showMetadata = false }, - onSave = { enabled, provider, language, key -> - onSaveMetadata(enabled, provider, language, key) - showMetadata = false - }, - ) - } -} - -@Composable -private fun MetadataSettingsDialog( - current: uniffi.pstr_android.MetadataSettingsRecord, - onDismiss: () -> Unit, - onSave: (Boolean, MetadataProvider, String, String) -> Unit, -) { - var enabled by remember { mutableStateOf(current.enabled) } - var provider by remember { mutableStateOf(current.provider) } - var language by remember { mutableStateOf(current.language) } - var apiKey by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = onDismiss, - properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), - title = { Text("Metadata enrichment") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy.") - SettingToggle("Enable enrichment", enabled) { enabled = it } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MetadataProvider.entries.forEach { option -> - if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) } - else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) } - } - } - Text( - if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required." - else "Film and television; requires a free TMDB API key.", - style = MaterialTheme.typography.bodySmall, - ) - if (provider == MetadataProvider.TMDB) { - OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true) - OutlinedTextField( - apiKey, - { apiKey = it }, - label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") }, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - singleLine = true, - ) - } - } - }, - confirmButton = { - AccentButton( - enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(), - onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) }, - ) { Text("Save") } - }, - dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} - -private fun MetadataProvider.displayName(): String = when (this) { - MetadataProvider.ANI_LIST -> "AniList" - MetadataProvider.TMDB -> "TMDB" -} - -private data class LegalDocument(val title: String, val assetPath: String) - -@Composable -private fun LegalDocumentDialog(document: LegalDocument, onDismiss: () -> Unit) { - val context = LocalContext.current - var contents by remember(document.assetPath) { mutableStateOf("Loading…") } - LaunchedEffect(document.assetPath) { - contents = runCatching { - withContext(Dispatchers.IO) { - context.assets.open(document.assetPath).bufferedReader().use { it.readText() } - } - }.getOrElse { error -> "Unable to load this document: ${error.message}" } - } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(document.title) }, - text = { - LazyColumn(Modifier.fillMaxWidth().heightIn(max = 520.dp)) { - item { Text(contents, style = MaterialTheme.typography.bodySmall) } - } - }, - confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } }, - ) -} - -@Composable -private fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) { - Row(Modifier.fillMaxWidth().padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { - Text(label, Modifier.weight(1f)) - Switch(checked = checked, onCheckedChange = onChecked) - } -} - -/** - * Flavour, accent and gradients — the same three the desktop client offers. - * - * Every colour is resolved by Rust, so a swatch here is the colour the app will - * actually paint rather than an approximation of it, and the choice is stored in - * the file both clients read. - */ -@Composable -private fun AppearancePicker() { - val scope = rememberCoroutineScope() - var choice by remember { mutableStateOf(null) } - var swatches by remember { mutableStateOf>(emptyMap()) } - - suspend fun repaint(next: AppearanceRecord, store: Boolean) { - runCatching { - withContext(Dispatchers.IO) { - val engine = NativeRuntime.engine() - if (store) engine.setAppearance(next) - val palette = engine.previewPalette(next) - // Every accent as it would look in *this* flavour: a swatch row - // that keeps Mocha's pastels while Latte is selected is a row - // that lies about what the next tap does. - val row = AccentChoice.entries.associateWith { accent -> - Color(engine.previewPalette(next.copy(accent = accent)).accent.toInt()) - } - palette to row - } - }.onSuccess { (palette, row) -> - choice = next - swatches = row - AppearanceState.apply(palette) - } - } - - LaunchedEffect(Unit) { - runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().appearance() } } - .onSuccess { repaint(it, store = false) } - } - - val current = choice ?: return - Text("Palette", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) { - items(FlavorChoice.entries.toList(), key = { it.name }) { flavor -> - val selected = flavor == current.flavor - if (selected) { - AccentButton(onClick = {}) { Text(flavor.label()) } - } else { - EdgedButton(onClick = { - scope.launch { repaint(current.copy(flavor = flavor), store = true) } - }) { Text(flavor.label()) } - } - } - } - Text("Accent", style = MaterialTheme.typography.bodyMedium) - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(vertical = 8.dp)) { - items(AccentChoice.entries.toList(), key = { it.name }) { accent -> - val swatch = swatches[accent] ?: MaterialTheme.colorScheme.surfaceVariant - Box( - Modifier - .size(36.dp) - .clip(CircleShape) - .background(swatch) - .border( - width = if (accent == current.accent) 3.dp else 1.dp, - color = if (accent == current.accent) { - MaterialTheme.colorScheme.onBackground - } else { - MaterialTheme.colorScheme.outline - }, - shape = CircleShape, - ) - .clickable { - scope.launch { repaint(current.copy(accent = accent), store = true) } - }, - ) - } - } - SettingToggle("Paint the accent as a gradient", current.gradients) { on -> - scope.launch { repaint(current.copy(gradients = on), store = true) } - } - Text( - "Off is the safer setting on a panel that bands: a slow ramp across a wide bar " + - "shows every step it is drawn from, and flat is better than striped.", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(bottom = 14.dp), - ) -} - -/** What each palette family is called. The desktop client says the same. */ -private fun FlavorChoice.label() = when (this) { - FlavorChoice.PROTON -> "Proton" - FlavorChoice.LATTE -> "Catppuccin Latte" - FlavorChoice.FRAPPE -> "Catppuccin Frappé" - FlavorChoice.MACCHIATO -> "Catppuccin Macchiato" - FlavorChoice.MOCHA -> "Catppuccin Mocha" - FlavorChoice.PERSONA5 -> "Persona 5" -} - -/** - * One language tag, committed as it is typed. - * - * Blank is a real answer and means "no preference" — the bridge stores it as - * absent, which is what leaves the choice to the container's own default track. - */ -@Composable -private fun LanguageField(label: String, value: String?, onChange: (String?) -> Unit) { - OutlinedTextField( - value = value.orEmpty(), - onValueChange = { onChange(it.trim().takeIf(String::isNotEmpty)) }, - label = { Text(label) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) -} - -@Composable -private fun EmptyState(title: String, body: String) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(title, style = MaterialTheme.typography.titleMedium) - Text(body, style = MaterialTheme.typography.bodyMedium) - } - } -} - -internal fun formatBytes(bytes: ULong): String { - val value = bytes.toDouble() - return when { - value >= 1024 * 1024 * 1024 -> "%.1f GiB".format(value / (1024 * 1024 * 1024)) - value >= 1024 * 1024 -> "%.1f MiB".format(value / (1024 * 1024)) - value >= 1024 -> "%.1f KiB".format(value / 1024) - else -> "$bytes bytes" - } -} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt new file mode 100644 index 0000000..9feecb7 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt @@ -0,0 +1,449 @@ +package io.narl.protonstream.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.border +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.LazyRow +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.compose.ui.unit.dp +import io.narl.protonstream.settings.SettingsStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import io.narl.protonstream.download.DownloadCoordinator +import io.narl.protonstream.native.NativeRuntime +import io.narl.protonstream.ui.theme.AccentButton +import io.narl.protonstream.ui.theme.AppearanceState +import io.narl.protonstream.ui.theme.EdgedButton +import io.narl.protonstream.ui.theme.TonalButton +import uniffi.pstr_android.MetadataProvider +import uniffi.pstr_android.FlavorChoice +import uniffi.pstr_android.AppearanceRecord +import uniffi.pstr_android.AccentChoice +import uniffi.pstr_android.PlaybackPrefsRecord + +@Composable +internal fun SettingsScreen( + state: AppUiState, + onSaveMetadata: (Boolean, MetadataProvider, String, String) -> Unit, + onMatchAgain: () -> Unit, + onClearCache: () -> Unit, + onRemoveAllOffline: () -> Unit, + padding: PaddingValues, +) { + val context = LocalContext.current + val settings = remember { SettingsStore(context) } + var wifiOnly by remember { mutableStateOf(settings.wifiOnly) } + var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) } + // Playback preferences are the shared store's, not this app's: they are the + // same file the desktop client reads, so a language chosen on one is the + // language the other starts in. + var prefs by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + fun update(change: (PlaybackPrefsRecord) -> PlaybackPrefsRecord) { + val next = change(prefs ?: return) + prefs = next + scope.launch(Dispatchers.IO) { + runCatching { NativeRuntime.engine().setPlaybackPrefs(next) } + } + } + LaunchedEffect(Unit) { + runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } } + .onSuccess { prefs = it } + } + var confirmDelete by remember { mutableStateOf(false) } + var showMetadata by remember { mutableStateOf(false) } + var legalDocument by remember { mutableStateOf(null) } + Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState())) { + Text("Settings", style = MaterialTheme.typography.titleLarge) + SettingToggle("Download on Wi-Fi only", wifiOnly) { + wifiOnly = it + settings.wifiOnly = it + // Constraints are baked in at enqueue time, so a queue that already + // exists keeps the policy it was queued under until it is re-issued. + DownloadCoordinator.applyNetworkPolicy(context) + } + HorizontalDivider() + SettingToggle("Continue audio in the background", backgroundAudio) { + backgroundAudio = it + settings.backgroundAudio = it + } + Text( + "When disabled, leaving playback stops the player instead of keeping a media notification active.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 14.dp), + ) + HorizontalDivider() + Text("Playback", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) + prefs?.let { current -> + SettingToggle("Play the next episode automatically", current.autoplayNext) { on -> + update { it.copy(autoplayNext = on) } + } + SettingToggle("Skip openings and endings automatically", current.autoSkip) { on -> + update { it.copy(autoSkip = on) } + } + Text( + "Openings and endings are read from the chapters a release was muxed with. " + + "With this off you get a Skip button instead, which is the safer default: " + + "a mis-named chapter then costs a tap rather than a scene.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 14.dp), + ) + SettingToggle("Show subtitles", current.subtitles) { on -> + update { it.copy(subtitles = on) } + } + // Language tags, not a picker: which languages exist is a property + // of each file, and a list built from one episode is wrong for the + // next. A show that has been given its own choice keeps it. + LanguageField("Preferred audio language", current.audioLanguage) { tag -> + update { it.copy(audioLanguage = tag) } + } + LanguageField("Preferred subtitle language", current.subtitleLanguage) { tag -> + update { it.copy(subtitleLanguage = tag) } + } + Text( + "Three-letter tags as they appear in the file — \"jpn\", \"eng\". Used for every " + + "title that has not been given a track choice of its own.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 14.dp), + ) + } + HorizontalDivider() + Text("Appearance", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) + AppearancePicker() + HorizontalDivider() + Text("Metadata enrichment", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) + Text( + if (state.metadataSettings.enabled) "On · ${state.metadataSettings.provider.displayName()}" + else "Off (privacy default)", + ) + Text( + "Enabling this sends library title names to the selected third-party provider over HTTPS.", + style = MaterialTheme.typography.bodySmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 10.dp)) { + TonalButton(onClick = { showMetadata = true }) { Text("Configure metadata") } + // Every title looked up again, matched ones included: the way out of + // a library the provider answered wrong, which otherwise stays wrong + // for as long as the match is remembered. + TonalButton( + onClick = { onMatchAgain() }, + enabled = state.metadataSettings.enabled && !state.refreshing, + ) { Text("Match everything again") } + } + HorizontalDivider() + Text("Storage", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) + Text("Offline media is encrypted at rest by Android and kept in app-private storage.") + Text( + "${state.storage.offlineCount} episodes offline · ${formatBytes(state.storage.offlineBytes)}", + modifier = Modifier.padding(top = 8.dp), + ) + if (state.storage.partialBytes > 0uL) { + Text( + "Unfinished downloads · ${formatBytes(state.storage.partialBytes)}", + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + "Streaming cache · ${formatBytes(state.storage.cacheBytes)}", + style = MaterialTheme.typography.bodySmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 10.dp)) { + // The cache is rebuildable, so it goes without asking. Offline + // episodes are a choice the viewer made, so that one asks. + TonalButton(onClick = onClearCache) { Text("Clear cache") } + TonalButton( + onClick = { confirmDelete = true }, + enabled = state.storage.offlineCount > 0uL, + ) { Text("Delete all offline") } + } + Text("proton-stream Android · GPL-3.0-or-later", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 24.dp)) + Text( + "This program comes with absolutely no warranty. You may redistribute it under the GNU GPL.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) { + TonalButton(onClick = { + legalDocument = LegalDocument("GNU GPL v3", "licenses/GPL-3.0.txt") + }) { Text("View license") } + TonalButton(onClick = { + legalDocument = LegalDocument("Third-party notices", "licenses/THIRD_PARTY_NOTICES.md") + }) { Text("View notices") } + } + } + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text("Delete all offline episodes?") }, + text = { + Text( + "${state.storage.offlineCount} episodes (${formatBytes(state.storage.offlineBytes)}) " + + "will be removed from this device. Watch history is kept, and anything " + + "deleted can be downloaded again.", + ) + }, + confirmButton = { + AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") } + }, + dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, + ) + } + legalDocument?.let { document -> + LegalDocumentDialog(document, onDismiss = { legalDocument = null }) + } + if (showMetadata) { + MetadataSettingsDialog( + current = state.metadataSettings, + onDismiss = { showMetadata = false }, + onSave = { enabled, provider, language, key -> + onSaveMetadata(enabled, provider, language, key) + showMetadata = false + }, + ) + } +} + +@Composable +private fun MetadataSettingsDialog( + current: uniffi.pstr_android.MetadataSettingsRecord, + onDismiss: () -> Unit, + onSave: (Boolean, MetadataProvider, String, String) -> Unit, +) { + var enabled by remember { mutableStateOf(current.enabled) } + var provider by remember { mutableStateOf(current.provider) } + var language by remember { mutableStateOf(current.language) } + var apiKey by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + title = { Text("Metadata enrichment") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy.") + SettingToggle("Enable enrichment", enabled) { enabled = it } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MetadataProvider.entries.forEach { option -> + if (option == provider) AccentButton(onClick = { provider = option }) { Text(option.displayName()) } + else EdgedButton(onClick = { provider = option }) { Text(option.displayName()) } + } + } + Text( + if (provider == MetadataProvider.ANI_LIST) "Anime; no account or API key required." + else "Film and television; requires a free TMDB API key.", + style = MaterialTheme.typography.bodySmall, + ) + if (provider == MetadataProvider.TMDB) { + OutlinedTextField(language, { language = it }, label = { Text("Language") }, singleLine = true) + OutlinedTextField( + apiKey, + { apiKey = it }, + label = { Text(if (current.ready) "TMDB API key (leave blank to keep)" else "TMDB API key") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + ) + } + } + }, + confirmButton = { + AccentButton( + enabled = !enabled || provider != MetadataProvider.TMDB || current.ready || apiKey.isNotBlank(), + onClick = { onSave(enabled, provider, language.ifBlank { "en" }, apiKey) }, + ) { Text("Save") } + }, + dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +internal fun MetadataProvider.displayName(): String = when (this) { + MetadataProvider.ANI_LIST -> "AniList" + MetadataProvider.TMDB -> "TMDB" +} + +private data class LegalDocument(val title: String, val assetPath: String) + +@Composable +private fun LegalDocumentDialog(document: LegalDocument, onDismiss: () -> Unit) { + val context = LocalContext.current + var contents by remember(document.assetPath) { mutableStateOf("Loading…") } + LaunchedEffect(document.assetPath) { + contents = runCatching { + withContext(Dispatchers.IO) { + context.assets.open(document.assetPath).bufferedReader().use { it.readText() } + } + }.getOrElse { error -> "Unable to load this document: ${error.message}" } + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(document.title) }, + text = { + LazyColumn(Modifier.fillMaxWidth().heightIn(max = 520.dp)) { + item { Text(contents, style = MaterialTheme.typography.bodySmall) } + } + }, + confirmButton = { AccentButton(onClick = onDismiss) { Text("Close") } }, + ) +} + +@Composable +internal fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) { + Row(Modifier.fillMaxWidth().padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = onChecked) + } +} + +/** + * Flavour, accent and gradients — the same three the desktop client offers. + * + * Every colour is resolved by Rust, so a swatch here is the colour the app will + * actually paint rather than an approximation of it, and the choice is stored in + * the file both clients read. + */ +@Composable +private fun AppearancePicker() { + val scope = rememberCoroutineScope() + var choice by remember { mutableStateOf(null) } + var swatches by remember { mutableStateOf>(emptyMap()) } + + suspend fun repaint(next: AppearanceRecord, store: Boolean) { + runCatching { + withContext(Dispatchers.IO) { + val engine = NativeRuntime.engine() + if (store) engine.setAppearance(next) + val palette = engine.previewPalette(next) + // Every accent as it would look in *this* flavour: a swatch row + // that keeps Mocha's pastels while Latte is selected is a row + // that lies about what the next tap does. + val row = AccentChoice.entries.associateWith { accent -> + Color(engine.previewPalette(next.copy(accent = accent)).accent.toInt()) + } + palette to row + } + }.onSuccess { (palette, row) -> + choice = next + swatches = row + AppearanceState.apply(palette) + } + } + + LaunchedEffect(Unit) { + runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().appearance() } } + .onSuccess { repaint(it, store = false) } + } + + val current = choice ?: return + Text("Palette", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) { + items(FlavorChoice.entries.toList(), key = { it.name }) { flavor -> + val selected = flavor == current.flavor + if (selected) { + AccentButton(onClick = {}) { Text(flavor.label()) } + } else { + EdgedButton(onClick = { + scope.launch { repaint(current.copy(flavor = flavor), store = true) } + }) { Text(flavor.label()) } + } + } + } + Text("Accent", style = MaterialTheme.typography.bodyMedium) + LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(vertical = 8.dp)) { + items(AccentChoice.entries.toList(), key = { it.name }) { accent -> + val swatch = swatches[accent] ?: MaterialTheme.colorScheme.surfaceVariant + Box( + Modifier + .size(36.dp) + .clip(CircleShape) + .background(swatch) + .border( + width = if (accent == current.accent) 3.dp else 1.dp, + color = if (accent == current.accent) { + MaterialTheme.colorScheme.onBackground + } else { + MaterialTheme.colorScheme.outline + }, + shape = CircleShape, + ) + .clickable { + scope.launch { repaint(current.copy(accent = accent), store = true) } + }, + ) + } + } + SettingToggle("Paint the accent as a gradient", current.gradients) { on -> + scope.launch { repaint(current.copy(gradients = on), store = true) } + } + Text( + "Off is the safer setting on a panel that bands: a slow ramp across a wide bar " + + "shows every step it is drawn from, and flat is better than striped.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 14.dp), + ) +} + +/** What each palette family is called. The desktop client says the same. */ +private fun FlavorChoice.label() = when (this) { + FlavorChoice.PROTON -> "Proton" + FlavorChoice.LATTE -> "Catppuccin Latte" + FlavorChoice.FRAPPE -> "Catppuccin Frappé" + FlavorChoice.MACCHIATO -> "Catppuccin Macchiato" + FlavorChoice.MOCHA -> "Catppuccin Mocha" + FlavorChoice.PERSONA5 -> "Persona 5" +} + +/** + * One language tag, committed as it is typed. + * + * Blank is a real answer and means "no preference" — the bridge stores it as + * absent, which is what leaves the choice to the container's own default track. + */ +@Composable +private fun LanguageField(label: String, value: String?, onChange: (String?) -> Unit) { + OutlinedTextField( + value = value.orEmpty(), + onValueChange = { onChange(it.trim().takeIf(String::isNotEmpty)) }, + label = { Text(label) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt new file mode 100644 index 0000000..f62004a --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt @@ -0,0 +1,188 @@ +package io.narl.protonstream.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.runtime.Composable +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.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.compose.ui.unit.dp +import io.narl.protonstream.ui.theme.AccentButton +import io.narl.protonstream.ui.theme.QuietButton +import io.narl.protonstream.ui.theme.TonalButton +import uniffi.pstr_android.ShareRecord + +@Composable +internal fun SharesScreen( + shares: List, + onAdd: (String, String, String?) -> Unit, + onRepair: (String, String, String?) -> Unit, + onRefresh: (String) -> Unit, + onRemove: (String) -> Unit, + padding: PaddingValues, +) { + var showAdd by remember { mutableStateOf(false) } + var repairing by remember { mutableStateOf(null) } + Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("Proton Drive public links", style = MaterialTheme.typography.titleLarge) + AccentButton(onClick = { showAdd = true }) { Text("Add share") } + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 16.dp)) { + items(shares, key = { it.id }) { share -> + Card(Modifier.fillMaxWidth()) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(share.name, fontWeight = FontWeight.SemiBold) + Text(if (share.hasCustomPassword) "Custom password stored securely" else "Public link") + } + // One share, not the library: a link that has just had + // files added to it should not cost a walk of every + // other one, and a link that has expired should not stop + // the ones that still work from being refreshed. + IconButton(onClick = { onRefresh(share.id) }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh this share") + } + // Not a remove: a share whose secret has become + // unreadable cannot be removed either, since removal + // deletes a secret the store can no longer touch. + QuietButton(onClick = { repairing = share }) { Text("Re-enter link") } + TonalButton(onClick = { onRemove(share.id) }) { Text("Remove") } + } + } + } + } + } + if (showAdd) AddShareDialog(onDismiss = { showAdd = false }, onAdd = onAdd) + repairing?.let { share -> + RepairShareDialog( + share = share, + onDismiss = { repairing = null }, + onRepair = { url, password -> onRepair(share.id, url, password) }, + ) + } +} + +/** + * Re-enter the link for a share the app can no longer decrypt its secret for. + * + * It has to be the *same* link — a different token is a different share, and + * repointing this one at it would leave every catalog row and every offline file + * describing something that is not there. Rust enforces that; this only says so. + */ +@Composable +private fun RepairShareDialog( + share: ShareRecord, + onDismiss: () -> Unit, + onRepair: (String, String?) -> Unit, +) { + var url by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + title = { Text("Re-enter link for ${share.name}") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "The stored credentials for this share cannot be read — usually after a " + + "screen-lock change or a device restore. Entering the same link again " + + "restores access without losing the library or anything downloaded.", + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + url, + { url = it }, + label = { Text("Public share URL") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + OutlinedTextField( + password, + { password = it }, + label = { Text("Custom password (optional)") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + } + }, + confirmButton = { + AccentButton( + onClick = { onRepair(url.trim(), password); onDismiss() }, + enabled = url.isNotBlank(), + ) { Text("Restore") } + }, + dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun AddShareDialog(onDismiss: () -> Unit, onAdd: (String, String, String?) -> Unit) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + title = { Text("Add Proton Drive share") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(name, { name = it }, label = { Text("Library name") }, singleLine = true) + OutlinedTextField( + url, + { url = it }, + label = { Text("Public share URL") }, + singleLine = true, + // The URL fragment is a secret. Password input disables + // IME learning/suggestions while normal long-press paste + // remains available. + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + OutlinedTextField( + password, + { password = it }, + label = { Text("Custom password (optional)") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + } + }, + confirmButton = { + AccentButton( + onClick = { onAdd(name.trim(), url.trim(), password); onDismiss() }, + enabled = name.isNotBlank() && url.isNotBlank(), + ) { Text("Add") } + }, + dismissButton = { TonalButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt new file mode 100644 index 0000000..de57af2 --- /dev/null +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -0,0 +1,571 @@ +package io.narl.protonstream.ui + +import android.content.Intent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.work.WorkManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import io.narl.protonstream.download.DownloadCoordinator +import io.narl.protonstream.download.DownloadStateStore +import io.narl.protonstream.download.RetainedDownload +import io.narl.protonstream.native.NativeRuntime +import io.narl.protonstream.ui.theme.AccentButton +import io.narl.protonstream.ui.theme.AccentProgress +import io.narl.protonstream.ui.theme.EdgedButton +import io.narl.protonstream.ui.theme.QuietButton +import io.narl.protonstream.ui.theme.TonalButton +import io.narl.protonstream.ui.theme.solid +import uniffi.pstr_android.TitleRecord +import uniffi.pstr_android.TrackPreferencesRecord +import uniffi.pstr_android.EpisodeRecord +import uniffi.pstr_android.SeasonRecord +import uniffi.pstr_android.MatchRecord + +@Composable +internal fun TitleScreen( + title: TitleRecord, + playerReady: Boolean, + onPlay: (List, Int) -> Unit, + onBack: () -> Unit, + onPreferenceError: (Throwable) -> Unit, + onMetadataChanged: () -> Unit, + onSetWatched: (EpisodeRecord, Boolean) -> Unit, + padding: PaddingValues, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var audioLanguage by remember(title.key) { mutableStateOf("") } + var subtitleLanguage by remember(title.key) { mutableStateOf("") } + var subtitlesEnabled by remember(title.key) { mutableStateOf(false) } + var showMatch by remember(title.key) { mutableStateOf(false) } + var expandedSeasons by remember(title.key) { mutableStateOf(setOf(title.seasons.firstOrNull()?.label)) } + + BackHandler(onBack = onBack) + + LaunchedEffect(title.key) { + runCatching { + withContext(Dispatchers.IO) { + NativeRuntime.engine().titleTrackPreferences(title.key) + } + }.onSuccess { preferences -> + // Absent means this title has never been given a choice; the fields + // are then empty and the global preference is what plays. + audioLanguage = preferences?.audioLanguage.orEmpty() + subtitleLanguage = preferences?.subtitleLanguage.orEmpty() + subtitlesEnabled = preferences?.subtitles ?: true + }.onFailure(onPreferenceError) + } + // Display order across seasons: what previous/next and autoplay walk, and + // the same order the desktop client uses. + val playlist = remember(title) { title.seasons.flatMap(SeasonRecord::episodes) } + // Where a press on Play lands. Part-watched wins over unwatched, most + // recently played first, which is the order the desktop client resumes in. + val nextUp = remember(playlist) { + val resumable = playlist.withIndex().filter { it.value.resumeAt != null } + resumable.maxByOrNull { it.value.lastPlayed }?.index + ?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 } + ?: 0 + } + // Live download state per episode, so a row can show progress and be paused + // without a trip to the Downloads tab. + val work by WorkManager.getInstance(context) + .getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList()) + val downloads = remember(work) { DownloadStateStore(context).records() } + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item { + QuietButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Back to library") + } + RemoteArtwork( + title.backdropUrl ?: title.posterUrl, + title.canonicalName ?: title.name, + Modifier.fillMaxWidth().aspectRatio(16f / 9f).padding(top = 12.dp), + fallback = title.thumbnailSource, + ) + Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(top = 12.dp)) + title.originalName?.takeIf { it != title.canonicalName }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + Text( + listOfNotNull( + (title.metadataYear ?: title.year)?.toString(), + title.rating?.let { "%.1f/10".format(it) }, + title.genres.takeIf { it.isNotEmpty() }?.joinToString(" · "), + ).joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 6.dp), + ) + title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) } + // Wrapping, not squeezing: a fixed row hands the last button the + // width the others left over, which is how "More on AniList" ended + // up set one letter per line on a phone. + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) { + // What a press on the poster should play: whatever was left + // part-watched, else the first unwatched episode, else the + // first — the desktop client's `next_up`. + AccentButton(onClick = { onPlay(playlist, nextUp) }, enabled = playerReady && playlist.isNotEmpty()) { + Text( + when { + !playerReady -> "Player loading…" + playlist.getOrNull(nextUp)?.resumeAt != null -> "Resume" + title.watchedCount > 0uL -> "Continue" + else -> "Play" + }, + ) + } + // Only where it would do something different: an unstarted show + // already starts at the beginning. + if (playlist.getOrNull(nextUp)?.resumeAt != null || title.watchedCount > 0uL) { + TonalButton( + onClick = { onPlay(playlist, 0) }, + enabled = playerReady && playlist.isNotEmpty(), + ) { Text("Start over") } + } + // The rest in the same flow, and all the same kind of button: + // one filled primary for the thing the page is for, tonal for + // everything else. A text link among filled buttons was what + // made this block read as three unrelated rows of controls. + TonalButton(onClick = { DownloadCoordinator.enqueue(context, playlist) }) { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Download show") + } + TonalButton(onClick = { showMatch = true }) { + Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Change match") + } + // The provider's own page for this title: where a viewer goes to + // check that the thing the app matched is the thing they have. + title.externalUrl?.let { url -> + EdgedButton(onClick = { + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + }.onFailure(onPreferenceError) + }) { Text("More on ${title.metadataProvider?.displayName() ?: "the provider"}") } + } + } + // Said plainly, because it changes what a re-match will do: a + // hand-picked match is not overwritten by an automatic pass. + if (title.manualMatch) { + Text( + "Matched by hand", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } + Text("${title.watchedCount} of ${title.episodeCount} watched", Modifier.padding(vertical = 12.dp)) + Text("Preferred tracks", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + audioLanguage, + { audioLanguage = it }, + label = { Text("Audio language") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + OutlinedTextField( + subtitleLanguage, + { subtitleLanguage = it }, + label = { Text("Subtitle language") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + } + SettingToggle("Enable subtitles", subtitlesEnabled) { subtitlesEnabled = it } + TonalButton(onClick = { + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + NativeRuntime.engine().setTitleTrackPreferences( + title.key, + TrackPreferencesRecord( + audioLanguage.takeIf(String::isNotBlank), + subtitleLanguage.takeIf(String::isNotBlank), + subtitlesEnabled, + ), + ) + } + }.onFailure(onPreferenceError) + } + }) { Text("Save track preferences") } + } + title.seasons.forEach { season -> + val isExpanded = expandedSeasons.contains(season.label) + item(key = "season/${season.label}") { + // A header, not a row with a button parked on the end of it: the + // label and its count are the only things that grow, and the one + // action is an icon of fixed width, so every season header down + // the page starts and ends in the same place. + Row( + Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .clip(MaterialTheme.shapes.medium) + .clickable { + expandedSeasons = if (isExpanded) expandedSeasons - season.label else expandedSeasons + season.label + } + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + ) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + season.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val watched = season.episodes.count(EpisodeRecord::watched) + Text( + "${season.episodes.size} " + + (if (season.episodes.size == 1) "episode" else "episodes") + + if (watched > 0) " · $watched watched" else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { DownloadCoordinator.enqueue(context, season.episodes) }) { + Icon( + Icons.Default.Download, + contentDescription = "Download ${season.label}", + ) + } + } + } + if (isExpanded) { + itemsIndexed(season.episodes, key = { _, it -> it.linkId }) { position, episode -> + EpisodeRow( + episode = episode, + numbering = episode.numbering(season.number, position), + download = downloads.firstOrNull { + it.shareId == episode.shareId && it.linkId == episode.linkId + }, + playerReady = playerReady, + onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) }, + onDownload = { DownloadCoordinator.enqueue(context, episode) }, + onPause = { DownloadCoordinator.pause(context, it) }, + onResume = { DownloadCoordinator.resume(context, it) }, + onSetWatched = { onSetWatched(episode, it) }, + ) + } + } + } + } + if (showMatch) { + ChangeMatchDialog( + title = title, + onDismiss = { showMatch = false }, + onChanged = { showMatch = false; onMetadataChanged() }, + onError = onPreferenceError, + ) + } +} + +/** + * `S03E38`, `E38`, or the row's place in the season. + * + * Mirrors `Episode::numbering` in `pstr-core`, plus the desktop client's + * fallback to the season folder's number. The last case is deliberately *not* + * printed as an episode number — nothing in the name numbered this file, and + * `#4` says "fourth in the list" where `E04` would be a claim about the show. + * Some ordinal is required regardless: a season of rows that all read the same + * is a season the viewer cannot navigate at all. + */ +private fun EpisodeRecord.numbering(fallbackSeason: UInt?, position: Int): String { + val season = this.season ?: fallbackSeason + val number = this.number + return when { + season != null && number != null -> "S%02dE%02d".format(season.toInt(), number.toInt()) + number != null -> "E%02d".format(number.toInt()) + else -> "#${position + 1}" + } +} + +/** + * One episode: play it, see where it was left, and drive its download. + * + * The row *is* the play button — the still, the numbering and the name are one + * target, which is what lets the trailing controls be two icons in a fixed + * column rather than a second row of buttons under every episode. Both were + * needed while the row carried a full-width `Play`: it pushed the download and + * watched controls into whatever width was left, and forty of those down a + * season read as forty differently-arranged rows. + */ +@Composable +private fun EpisodeRow( + episode: EpisodeRecord, + numbering: String, + download: RetainedDownload?, + playerReady: Boolean, + onPlay: () -> Unit, + onDownload: () -> Unit, + onPause: (RetainedDownload) -> Unit, + onResume: (RetainedDownload) -> Unit, + onSetWatched: (Boolean) -> Unit, +) { + val running = download?.status == RetainedDownload.STATUS_RUNNING || + download?.status == RetainedDownload.STATUS_QUEUED + // The provider's name for the episode when there is one: "The Cave of + // Skulls" says more than the filename it was parsed out of. The filename + // stays underneath it, and is the whole answer when there is no provider. + val name = episode.providerName ?: episode.detail + val detail = episode.detail.takeIf { it != name } + Card( + Modifier + .fillMaxWidth() + .clickable(enabled = playerReady, onClick = onPlay), + ) { + Column(Modifier.padding(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + // With metadata off the provider has no still, and Proton's own + // thumbnail is a frame of this very episode. + Box( + Modifier + .width(96.dp) + .aspectRatio(16f / 9f) + .clip(MaterialTheme.shapes.small), + contentAlignment = Alignment.Center, + ) { + RemoteArtwork( + episode.stillUrl, + name, + Modifier.fillMaxSize(), + fallback = episode.thumbnailSource, + ) + // Over the still rather than beside it: the row already + // plays on tap, so this says so without spending a column. + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = Color.White, + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .padding(4.dp), + ) + } + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text( + numbering, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + Text( + name, + fontWeight = FontWeight.SemiBold, + // Seen episodes stay legible but stop competing with the + // one the viewer has not watched yet. + color = if (episode.watched) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + detail?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + // Watched is a judgement the viewer is allowed to overrule: a + // half-watched episode they are done with, or one the 90 % rule + // marked seen because they sat through the credits. + IconButton(onClick = { onSetWatched(!episode.watched) }) { + Icon( + if (episode.watched) Icons.Default.CheckCircle else Icons.Outlined.CheckCircle, + contentDescription = if (episode.watched) "Mark unwatched" else "Mark watched", + tint = if (episode.watched) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + ) + } + // One slot, always the same width, whatever state the download + // is in — including "there is nothing to say", where an empty + // box keeps the rows above and below it aligned. + Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { + when { + episode.offline -> Icon( + Icons.Default.CheckCircle, + contentDescription = "Saved offline", + tint = MaterialTheme.colorScheme.tertiary, + ) + running -> IconButton(onClick = { onPause(download!!) }) { + Icon(Icons.Default.Pause, contentDescription = "Pause download") + } + download != null -> IconButton(onClick = { onResume(download) }) { + Icon(Icons.Default.Download, contentDescription = "Resume download") + } + else -> IconButton(onClick = onDownload) { + Icon(Icons.Default.Download, contentDescription = "Download") + } + } + } + } + // Where the episode was left, under the row it belongs to. + episode.progress?.takeIf { !episode.watched }?.let { progress -> + AccentProgress( + progress = { progress.toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + download?.takeIf { it.total > 0L && !episode.offline }?.let { active -> + AccentProgress( + progress = { (active.downloaded.toFloat() / active.total).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + fill = solid(MaterialTheme.colorScheme.tertiary), + ) + Text( + "${active.status.replaceFirstChar { it.uppercase() }} · " + + "${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun ChangeMatchDialog( + title: TitleRecord, + onDismiss: () -> Unit, + onChanged: () -> Unit, + onError: (Throwable) -> Unit, +) { + var term by remember(title.key) { mutableStateOf(title.canonicalName ?: title.name) } + var searching by remember { mutableStateOf(false) } + var options by remember { mutableStateOf>(emptyList()) } + val scope = rememberCoroutineScope() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Change match") }, + text = { + Column { + Text("Search the configured metadata provider. Nothing is stored until you choose an entry.") + OutlinedTextField(term, { term = it }, Modifier.fillMaxWidth().padding(vertical = 8.dp), singleLine = true) + AccentButton(enabled = term.isNotBlank() && !searching, onClick = { + searching = true + scope.launch { + runCatching { + withContext(Dispatchers.IO) { NativeRuntime.engine().searchMatches(title.key, term) } + }.onSuccess { options = it }.onFailure(onError) + searching = false + } + }) { Text(if (searching) "Searching…" else "Search") } + LazyColumn(Modifier.fillMaxWidth().heightIn(max = 300.dp)) { + items(options, key = { "${it.provider}:${it.remoteId}" }) { option -> + EdgedButton( + onClick = { + scope.launch { + runCatching { + withContext(Dispatchers.IO) { NativeRuntime.engine().chooseMatch(title.key, option) } + }.onSuccess { onChanged() }.onFailure(onError) + } + }, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + ) { + Column(Modifier.fillMaxWidth()) { + Text(option.name, fontWeight = FontWeight.SemiBold) + Text(listOfNotNull(option.year?.toString(), option.originalName).joinToString(" · ")) + } + } + } + } + } + }, + confirmButton = { + QuietButton(onClick = { + scope.launch { + runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } } + .onSuccess { onChanged() }.onFailure(onError) + } + }) { Text("Forget match") } + }, + dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } }, + ) +}