diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt index 9d15cce..bffcdf0 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt @@ -302,7 +302,7 @@ fun PlayerScreen( NowPlaying( media = key, title = episode.label, - show = title.canonicalName ?: title.name, + show = title.displayName, detail = episode.detail, artworkUrl = title.posterUrl ?: title.backdropUrl, artworkFile = episode.thumbnailSource, @@ -425,7 +425,7 @@ fun PlayerScreen( style = MaterialTheme.typography.titleMedium, ) Text( - listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank)) + listOfNotNull(title.displayName, episode.detail.takeIf(String::isNotBlank)) .joinToString(" • "), color = Color.White.copy(alpha = 0.7f), maxLines = 1, diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index eb9bc80..cb27bc7 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -25,6 +25,8 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.withContext import io.narl.protonstream.native.NativeRuntime +import uniffi.pstr_android.ArrangementRecord +import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.OfflineRecord @@ -40,6 +42,14 @@ data class AppUiState( val refreshing: Boolean = false, val query: String = "", val titles: List = emptyList(), + /** + * The grid: [titles] ordered, each franchise folded into one tile when + * [grouped], and the shelves above it. Null until the bridge has answered, + * when the grid falls back to [titles] as they are. + */ + val arrangement: ArrangementRecord? = null, + val sort: LibrarySort = LibrarySort.NAME, + val grouped: Boolean = true, val shares: List = emptyList(), val offline: List = emptyList(), val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord( @@ -86,6 +96,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie } } + /** Reorder the grid, or fold or unfold its franchises. */ + fun arrange(sort: LibrarySort, grouped: Boolean) { + mutableState.update { it.copy(sort = sort, grouped = grouped) } + viewModelScope.launch { reloadLibrary(mutableState.value.query) } + } + fun search(query: String) { mutableState.update { it.copy(query = query) } searchQuery.value = query @@ -205,7 +221,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie val episodes = title.seasons.flatMap { it.episodes } changeWatch( episodes, - "${title.canonicalName ?: title.name} marked ${if (watched) "watched" else "unwatched"}", + "${title.displayName} marked ${if (watched) "watched" else "unwatched"}", ) { engine, episode, before -> val duration = before?.durationSecs engine.saveWatchState( @@ -225,7 +241,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) { changeWatch( listOf(episode), - "${title.canonicalName ?: title.name} removed from Continue watching", + "${title.displayName} removed from Continue watching", ) { engine, target, before -> engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false) } @@ -238,7 +254,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) { changeWatch( listOf(episode), - "${episode.label} of ${title.canonicalName ?: title.name} removed from history", + "${episode.label} of ${title.displayName} removed from history", ) { engine, target, before -> engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false) } @@ -444,9 +460,12 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie // and cached: a full reload is the one place that has // already paid for a walk of the offline files. engine.pruneOfflineFiles() + val current = mutableState.value + val query = current.query.takeIf(String::isNotBlank) Reloaded( engine.shares(), - engine.library(mutableState.value.query.takeIf(String::isNotBlank)), + engine.library(query), + engine.arrangement(query, current.sort, current.grouped), engine.offlineFiles(), engine.metadataSettings(), engine.storageUsage(), @@ -457,6 +476,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie loading = false, shares = result.shares, titles = result.titles, + arrangement = result.arrangement, offline = result.offline, metadataSettings = result.metadataSettings, storage = result.storage, @@ -469,10 +489,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie } private suspend fun reloadLibrary(query: String) { + val current = mutableState.value runCatching { - withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) } - }.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } } - .onFailure { error -> mutableState.update { it.copy(message = error.message) } } + withContext(Dispatchers.IO) { + val engine = NativeRuntime.engine() + val search = query.takeIf(String::isNotBlank) + engine.library(search) to engine.arrangement(search, current.sort, current.grouped) + } + }.onSuccess { (titles, arrangement) -> + mutableState.update { it.copy(titles = titles, arrangement = arrangement) } + }.onFailure { error -> mutableState.update { it.copy(message = error.message) } } } class Factory(private val context: Context, private val workManager: WorkManager) : ViewModelProvider.Factory { @@ -484,6 +510,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie private data class Reloaded( val shares: List, val titles: List, + val arrangement: ArrangementRecord, val offline: List, val metadataSettings: MetadataSettingsRecord, val storage: StorageUsageRecord, 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 index b1ff394..8ece293 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt @@ -74,7 +74,7 @@ internal fun DownloadsScreen( state.offline .groupBy { shows["${it.shareId}/${it.linkId}"] } .map { (title, files) -> - OfflineGroup(title?.let { it.canonicalName ?: it.name } ?: "Not in the library", title, files) + OfflineGroup(title?.displayName ?: "Not in the library", title, files) } .sortedBy { it.name } } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt index 07b58d6..0890bba 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt @@ -145,7 +145,7 @@ private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, o } Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) { Text( - entry.title.canonicalName ?: entry.title.name, + entry.title.displayName, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, 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 index e15eaaa..d89621e 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -76,7 +76,13 @@ 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 androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import uniffi.pstr_android.EpisodeRecord +import uniffi.pstr_android.LibrarySort import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleType @@ -92,6 +98,12 @@ 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) +/** One tile of the grid: the title drawn, and how many more of its franchise it stands for. */ +private data class Tile(val title: TitleRecord, val folded: Int) + +/** A row above the grid: titles that share a director, a studio or a genre. */ +private data class Shelf(val label: String, val titles: List) + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun LibraryScreen( @@ -105,6 +117,7 @@ internal fun LibraryScreen( padding: PaddingValues, onMatchChanged: () -> Unit = {}, onError: (Throwable) -> Unit = {}, + onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> }, ) { val context = LocalContext.current // What a long press opened a menu for: a title, or a Continue watching card. @@ -144,6 +157,7 @@ internal fun LibraryScreen( IconButton(onClick = { searching = true }) { Icon(Icons.Default.Search, contentDescription = "Search library") } + ArrangeMenu(state.sort, state.grouped, onArrange) // Pull to refresh is the gesture; the icon stays for a // viewer who does not know it. IconButton(onClick = onRefresh, enabled = !state.refreshing) { @@ -183,7 +197,7 @@ internal fun LibraryScreen( menuFor?.let { title -> val playlist = title.seasons.flatMap(SeasonRecord::episodes) val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount - TileMenu(title.canonicalName ?: title.name, title.caption(), onDismiss = { menuFor = null }) { + TileMenu(title.displayName, title.caption(), onDismiss = { menuFor = null }) { MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") { onResume(title, nextUpIndex(playlist)) } @@ -211,7 +225,7 @@ internal fun LibraryScreen( } menuResume?.let { entry -> TileMenu( - entry.title.canonicalName ?: entry.title.name, + entry.title.displayName, entry.episode.label, onDismiss = { menuResume = null }, ) { @@ -224,6 +238,56 @@ internal fun LibraryScreen( } } +/** + * The grid's order, and whether a franchise is one tile or one per title — + * the same choices the desktop offers beside its grid, behind one icon here + * because a phone's bar has room for three. + */ +@Composable +private fun ArrangeMenu(sort: LibrarySort, grouped: Boolean, onArrange: (LibrarySort, Boolean) -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { open = true }) { + Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = "Sort library") + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + LibrarySort.entries.forEach { option -> + DropdownMenuItem( + text = { Text(option.label()) }, + leadingIcon = { + if (option == sort) Icon(Icons.Default.Check, contentDescription = null) + }, + onClick = { + open = false + onArrange(option, grouped) + }, + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { Text("One tile per franchise") }, + leadingIcon = { + if (grouped) Icon(Icons.Default.Check, contentDescription = null) + }, + onClick = { + open = false + onArrange(sort, !grouped) + }, + ) + } + } +} + +/** What each order is called. The desktop says the same. */ +private fun LibrarySort.label() = when (this) { + LibrarySort.NAME -> "A – Z" + LibrarySort.RECENT -> "Recently watched" + LibrarySort.ADDED -> "Recently added" + LibrarySort.RELEASE -> "Newest" + LibrarySort.RATING -> "Highest rated" + LibrarySort.POPULARITY -> "Most popular" +} + /** * The bar while searching: back closes the search and clears it, and the field * takes the keyboard as soon as it opens, since opening it is asking to type. @@ -321,6 +385,18 @@ private fun LibraryGrid( onTitleMenu: (TitleRecord) -> Unit, onResumeMenu: (Resumable) -> Unit, ) { + // The arrangement names titles by key; before it has arrived, the titles + // as they came are the grid. + val byKey = remember(state.titles) { state.titles.associateBy { it.key } } + val tiles = remember(state.titles, state.arrangement) { + state.arrangement?.tiles?.mapNotNull { tile -> byKey[tile.key]?.let { Tile(it, tile.folded.toInt()) } } + ?: state.titles.map { Tile(it, 0) } + } + val shelves = remember(state.titles, state.arrangement) { + state.arrangement?.shelves.orEmpty() + .map { shelf -> Shelf(shelf.label, shelf.keys.mapNotNull(byKey::get)) } + .filter { it.titles.isNotEmpty() } + } // Posters, three across on a phone. Cropped backdrops at full width fitted // two titles to a screen, which is a library read one row at a time. LazyVerticalGrid( @@ -363,12 +439,42 @@ private fun LibraryGrid( } } } - item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) { - Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) + } + if (state.query.isBlank()) { + shelves.forEach { shelf -> + item(key = "shelf-${shelf.label}", span = { GridItemSpan(maxLineSpan) }) { + Column { + Text(shelf.label, style = MaterialTheme.typography.titleMedium) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(top = 10.dp), + ) { + items(shelf.titles, key = { it.key }) { title -> + PosterTile( + title, + onClick = { onTitle(title) }, + onLongClick = { onTitleMenu(title) }, + modifier = Modifier.width(SHELF_TILE_WIDTH), + shared = false, + ) + } + } + } + } + } + if (resumable.isNotEmpty() || shelves.isNotEmpty()) { + item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) { + Text("All titles", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) + } } } - items(state.titles, key = { it.key }) { title -> - PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitleMenu(title) }) + items(tiles, key = { it.title.key }) { tile -> + PosterTile( + tile.title, + onClick = { onTitle(tile.title) }, + onLongClick = { onTitleMenu(tile.title) }, + folded = tile.folded, + ) } } } @@ -399,6 +505,9 @@ private fun LibrarySkeleton() { } } +/** A shelf's covers: a little narrower than the grid's, so the row reads as a row. */ +private val SHELF_TILE_WIDTH = 112.dp + /** Enough placeholder tiles to fill a tablet's first screen. */ private const val SKELETON_TILES = 18 @@ -421,7 +530,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) { RemoteArtwork( title.backdropUrl ?: title.posterUrl, - title.canonicalName ?: title.name, + title.displayName, Modifier.fillMaxSize(), fallback = title.thumbnailSource, labelled = false, @@ -433,7 +542,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) ) Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) { Text( - title.canonicalName ?: title.name, + title.displayName, style = MaterialTheme.typography.headlineMedium, color = Color.White, maxLines = 2, @@ -481,7 +590,7 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) { RemoteArtwork( entry.episode.stillUrl ?: entry.title.backdropUrl, - entry.title.canonicalName ?: entry.title.name, + entry.title.displayName, Modifier.fillMaxSize(), fallback = entry.episode.thumbnailSource, ) @@ -493,7 +602,7 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () } } Text( - entry.title.canonicalName ?: entry.title.name, + entry.title.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -509,23 +618,55 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: () } } -/** One title: its poster, and its name and size under it rather than in a card. */ +/** + * One title: its poster, and its name and size under it rather than in a card. + * + * [folded] is how many more titles of its franchise the tile stands for, said + * in the corner. [shared] is whether the poster grows into the title page — + * only one copy of a title on screen can, and a shelf may hold the same title + * as the grid. + */ @Composable -private fun PosterTile(title: TitleRecord, onClick: () -> Unit, onLongClick: () -> Unit) { +private fun PosterTile( + title: TitleRecord, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + folded: Int = 0, + shared: Boolean = true, +) { Column( - Modifier + modifier .clip(MaterialTheme.shapes.medium) .combinedClickable(onClick = onClick, onLongClick = onLongClick), ) { - RemoteArtwork( - title.posterUrl ?: title.backdropUrl, - title.canonicalName ?: title.name, - Modifier.sharedArt(title.key).fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium), - fallback = title.thumbnailSource, - ) + Box { + RemoteArtwork( + title.posterUrl ?: title.backdropUrl, + title.displayName, + (if (shared) Modifier.sharedArt(title.key) else Modifier) + .fillMaxWidth() + .aspectRatio(2f / 3f) + .clip(MaterialTheme.shapes.medium), + fallback = title.thumbnailSource, + ) + if (folded > 0) { + Text( + "+$folded", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .clip(MaterialTheme.shapes.small) + .background(Color.Black.copy(alpha = 0.7f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } Spacer(Modifier.height(6.dp)) Text( - title.canonicalName ?: title.name, + title.displayName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, 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 423c8c8..087bb72 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 @@ -215,6 +215,7 @@ fun ProtonStreamApp( body, onMatchChanged = model::reloadAfterMetadataChange, onError = model::reportError, + onArrange = model::arrange, ) } val titlePane = @Composable { selected: TitleRecord -> @@ -231,6 +232,10 @@ fun ProtonStreamApp( model::reloadAfterMetadataChange, model::setWatched, body, + franchise = selected.franchise.mapNotNull { key -> + state.titles.firstOrNull { it.key == key } + }, + onOpenTitle = { selectedTitleKey = it.key }, ) } if (maxWidth >= TWO_PANE_WIDTH) { @@ -292,6 +297,7 @@ fun ProtonStreamApp( model::clearBlockCache, model::removeAllOffline, body, + onNamesChanged = model::reloadAfterMetadataChange, ) } } 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 index 887ea6e..95cc2fc 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt @@ -71,6 +71,7 @@ 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.TitleNamesChoice import uniffi.pstr_android.AccentChoice import uniffi.pstr_android.PlaybackPrefsRecord import androidx.compose.material3.ListItem @@ -94,6 +95,8 @@ internal fun SettingsScreen( // A parameter so the screenshot tests can pin it: read from the package, // every version bump changed the committed image of this page. version: String = appVersion(), + /** The library's names changed, and it has to be read again to show them. */ + onNamesChanged: () -> Unit = {}, ) { val context = LocalContext.current val settings = remember { SettingsStore(context) } @@ -264,7 +267,7 @@ internal fun SettingsScreen( ) } SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) { - Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker() } + Column(Modifier.padding(horizontal = 16.dp)) { AppearancePicker(onNamesChanged) } } SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) { SettingAction( @@ -502,7 +505,7 @@ internal fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean) * the file both clients read. */ @Composable -private fun AppearancePicker() { +private fun AppearancePicker(onNamesChanged: () -> Unit) { val scope = rememberCoroutineScope() var choice by remember { mutableStateOf(null) } var swatches by remember { mutableStateOf>(emptyMap()) } @@ -571,6 +574,26 @@ private fun AppearancePicker() { ) } } + Text("Title names", style = MaterialTheme.typography.bodyMedium) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) { + items(TitleNamesChoice.entries.toList(), key = { it.name }) { names -> + if (names == current.names) { + AccentButton(onClick = {}) { Text(names.label()) } + } else { + EdgedButton(onClick = { + scope.launch { + repaint(current.copy(names = names), store = true) + onNamesChanged() + } + }) { Text(names.label()) } + } + } + } + Text( + "What a matched title is called. Its other names still find it in search.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 8.dp), + ) SettingToggle("Paint the accent as a gradient", current.gradients) { on -> scope.launch { repaint(current.copy(gradients = on), store = true) } } @@ -582,6 +605,13 @@ private fun AppearancePicker() { ) } +/** What each name setting is called. The desktop client says the same. */ +private fun TitleNamesChoice.label() = when (this) { + TitleNamesChoice.LIBRARY -> "As in the share" + TitleNamesChoice.ENGLISH -> "English" + TitleNamesChoice.ROMAJI -> "Romaji" +} + /** What each palette family is called. The desktop client says the same. */ private fun FlavorChoice.label() = when (this) { FlavorChoice.PROTON -> "Proton" 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 index f9eb9a9..2e5f431 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -73,7 +73,9 @@ 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 androidx.compose.foundation.border import uniffi.pstr_android.TitleRecord +import uniffi.pstr_android.TitleType import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.MatchRecord @@ -95,6 +97,9 @@ internal fun TitleScreen( onMetadataChanged: () -> Unit, onSetWatched: (EpisodeRecord, Boolean) -> Unit, padding: PaddingValues, + /** Every title of its franchise, in release order, this one included. */ + franchise: List = emptyList(), + onOpenTitle: (TitleRecord) -> Unit = {}, ) { val context = LocalContext.current var showMatch by remember(title.key) { mutableStateOf(false) } @@ -165,7 +170,7 @@ internal fun TitleScreen( Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) { RemoteArtwork( title.backdropUrl ?: title.posterUrl, - title.canonicalName ?: title.name, + title.displayName, Modifier.fillMaxSize(), fallback = title.thumbnailSource, labelled = false, @@ -192,21 +197,40 @@ internal fun TitleScreen( } item(key = "heading") { Column(Modifier.padding(horizontal = side + 16.dp)) { - Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium) - title.originalName?.takeIf { it != title.canonicalName }?.let { + Text(title.displayName, style = MaterialTheme.typography.headlineMedium) + // The other names it goes by: the share's when the shown one + // is the provider's, and the original-language one. + listOfNotNull( + title.name.takeIf { it != title.displayName }, + title.originalName?.takeIf { it != title.displayName && it != title.canonicalName }, + ).joinToString(" · ").takeIf(String::isNotEmpty)?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } Text( listOfNotNull( - (title.metadataYear ?: title.year)?.toString(), + title.formatLabel, + title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(), title.rating?.let { "★ %.1f".format(it) }, - title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "), "${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL }, ).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp), ) + // Who made it, and whether more is coming — the desktop's + // line of facts, on a line of its own so neither wraps. + listOfNotNull( + title.studios.takeIf { it.isNotEmpty() }?.take(2)?.joinToString(", "), + title.directors.firstOrNull()?.let { "dir. $it" }, + airingNote(title), + ).joinToString(" · ").takeIf(String::isNotEmpty)?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } // The one thing the page is for, full width and naming what it // will play: "Resume" alone left the viewer guessing which // episode of forty it meant. @@ -271,6 +295,17 @@ internal fun TitleScreen( .clickable { overviewOpen = !overviewOpen }, ) } + // Genres as chips, then the provider's tags quieter after + // them: a tag is its community describing it, not what it + // is filed as. + if (title.genres.isNotEmpty() || title.tags.isNotEmpty()) { + Text( + (title.genres + title.tags.take(4)).joinToString(" · "), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 10.dp), + ) + } // 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) { @@ -283,6 +318,25 @@ internal fun TitleScreen( } } } + if (franchise.size > 1) { + item(key = "franchise") { + Column(Modifier.padding(top = 12.dp)) { + Text( + "In this franchise", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = side + 16.dp), + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp), + ) { + items(franchise, key = { it.key }) { member -> + FranchiseTile(member, here = member.key == title.key) { onOpenTitle(member) } + } + } + } + } + } item(key = "seasons") { Row( Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp), @@ -336,6 +390,68 @@ internal fun TitleScreen( } } +/** + * One part of the franchise: its cover, what it is and when. This title's own + * is marked rather than left out, so the row says where it sits in the story. + */ +@Composable +private fun FranchiseTile(title: TitleRecord, here: Boolean, onClick: () -> Unit) { + Column( + Modifier + .width(96.dp) + .clip(MaterialTheme.shapes.medium) + .clickable(enabled = !here, onClick = onClick), + ) { + RemoteArtwork( + title.posterUrl ?: title.backdropUrl, + title.displayName, + Modifier + .fillMaxWidth() + .aspectRatio(2f / 3f) + .clip(MaterialTheme.shapes.medium) + .then( + if (here) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium) + } else { + Modifier + }, + ), + fallback = title.thumbnailSource, + ) + Text( + title.displayName, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (here) FontWeight.SemiBold else null, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp), + ) + Text( + listOfNotNull( + title.formatLabel ?: if (title.kind == TitleType.FILM) "Film" else "Series", + (title.metadataYear ?: title.year)?.toString(), + ).joinToString(" · "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } +} + +/** + * "Episode 7 on Sat 11 Oct" while one is due, "Airing" while it is airing with + * no date yet, else nothing. + */ +private fun airingNote(title: TitleRecord): String? { + val at = title.nextAiringAt + val episode = title.nextEpisode + if (at != null && episode != null) { + val day = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault()) + return "episode $episode on " + day.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM")) + } + return if (title.airing) "airing" else null +} + /** How wide the page's text and controls run on a window wider than a phone. */ private val READABLE_WIDTH = 720.dp diff --git a/android/app/src/test/java/io/narl/protonstream/ui/HistoryTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/HistoryTest.kt index 5ee790f..7d4c2c7 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/HistoryTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/HistoryTest.kt @@ -23,6 +23,9 @@ class HistoryTest { metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null, backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null, manualMatch = false, seasons = listOf(SeasonRecord(1u, "Season 1", episodes.toList())), + displayName = key, wideUrl = null, formatLabel = null, seasonLabel = null, studios = emptyList(), + directors = emptyList(), tags = emptyList(), airing = false, nextEpisode = null, nextAiringAt = null, + franchise = emptyList(), ) @Test diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt index 16a6cb2..a720c18 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -25,12 +25,15 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode +import uniffi.pstr_android.ArrangementRecord import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.PaletteRecord import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.ShareRecord +import uniffi.pstr_android.ShelfRecord import uniffi.pstr_android.StorageUsageRecord +import uniffi.pstr_android.TileRecord import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleType @@ -129,7 +132,7 @@ class ScreenshotTest { @Test fun `a title with seasons`() = capture("title-phone") { - TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues()) + TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues(), franchise = listOf(SERIES, SEQUEL)) } @Test @@ -250,6 +253,17 @@ class ScreenshotTest { externalUrl = null, manualMatch = false, seasons = seasons, + displayName = name, + wideUrl = null, + formatLabel = null, + seasonLabel = null, + studios = emptyList(), + directors = emptyList(), + tags = emptyList(), + airing = false, + nextEpisode = null, + nextAiringAt = null, + franchise = emptyList(), ) val SERIES = title( @@ -260,8 +274,19 @@ class ScreenshotTest { SeasonRecord(2u, "Season 2", (1u..3u).map { episode(2u, it, progress = if (it == 1u) 0.4 else null) }), ), overview = "A doctor in a countryside clinic is reborn as the child of the idol he followed.", + ).copy( + formatLabel = "TV", + seasonLabel = "Spring 2023", + studios = listOf("Doga Kobo"), + directors = listOf("Daisuke Hiramaki"), + tags = listOf("Idol", "Reincarnation"), + franchise = listOf("oshi-no-ko", "oshi-no-ko-film"), ) + /** The rest of [SERIES]'s franchise, for the title page's row of it. */ + val SEQUEL = title("oshi-no-ko-film", "Oshi no Ko: The Final Act", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u))))) + .copy(kind = TitleType.FILM, year = 2024u, formatLabel = "Film", franchise = SERIES.franchise) + val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u))))) .copy(kind = TitleType.FILM, year = 1988u) @@ -273,6 +298,14 @@ class ScreenshotTest { title("ano-hana", "Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.", listOf(SeasonRecord(1u, "Season 1", (1u..11u).map { episode(1u, it) }))), title("bleach", "Bleach", listOf(SeasonRecord(1u, "Season 1", (1u..2u).map { episode(1u, it) }))), ), + // Bleach stands for one more title of its franchise, and one shelf + // sits above the grid: what the bridge's arrangement would give. + arrangement = ArrangementRecord( + tiles = listOf("akira", "ano-hana", "bleach", "oshi-no-ko").map { + TileRecord(it, if (it == "bleach") 1u else 0u) + }, + shelves = listOf(ShelfRecord("From MAPPA", listOf("oshi-no-ko", "bleach", "akira"))), + ), shares = listOf( ShareRecord("anime", "anime", true), ShareRecord("films", "films", false), diff --git a/android/app/src/test/screenshots/library-empty.png b/android/app/src/test/screenshots/library-empty.png index 6b20bbe..7db0f8a 100644 Binary files a/android/app/src/test/screenshots/library-empty.png and b/android/app/src/test/screenshots/library-empty.png differ diff --git a/android/app/src/test/screenshots/library-loading.png b/android/app/src/test/screenshots/library-loading.png index c0e897e..5a74776 100644 Binary files a/android/app/src/test/screenshots/library-loading.png and b/android/app/src/test/screenshots/library-loading.png differ diff --git a/android/app/src/test/screenshots/library-phone-large-font.png b/android/app/src/test/screenshots/library-phone-large-font.png index 50461f6..73ffca6 100644 Binary files a/android/app/src/test/screenshots/library-phone-large-font.png and b/android/app/src/test/screenshots/library-phone-large-font.png differ diff --git a/android/app/src/test/screenshots/library-phone-light.png b/android/app/src/test/screenshots/library-phone-light.png index 8aca376..10e81ec 100644 Binary files a/android/app/src/test/screenshots/library-phone-light.png and b/android/app/src/test/screenshots/library-phone-light.png differ diff --git a/android/app/src/test/screenshots/library-phone.png b/android/app/src/test/screenshots/library-phone.png index 868929e..4a281c3 100644 Binary files a/android/app/src/test/screenshots/library-phone.png and b/android/app/src/test/screenshots/library-phone.png differ diff --git a/android/app/src/test/screenshots/library-tablet.png b/android/app/src/test/screenshots/library-tablet.png index 86ba5a4..6273b4b 100644 Binary files a/android/app/src/test/screenshots/library-tablet.png and b/android/app/src/test/screenshots/library-tablet.png differ diff --git a/android/app/src/test/screenshots/title-phone-light.png b/android/app/src/test/screenshots/title-phone-light.png index d733529..321c60c 100644 Binary files a/android/app/src/test/screenshots/title-phone-light.png and b/android/app/src/test/screenshots/title-phone-light.png differ diff --git a/android/app/src/test/screenshots/title-phone.png b/android/app/src/test/screenshots/title-phone.png index bcc5b4c..45cd010 100644 Binary files a/android/app/src/test/screenshots/title-phone.png and b/android/app/src/test/screenshots/title-phone.png differ diff --git a/android/app/src/test/screenshots/title-tablet.png b/android/app/src/test/screenshots/title-tablet.png index 2489386..61c207b 100644 Binary files a/android/app/src/test/screenshots/title-tablet.png and b/android/app/src/test/screenshots/title-tablet.png differ diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index d3f4fd2..b7d51ff 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -11,12 +11,13 @@ use std::sync::{Arc, OnceLock}; use parking_lot::Mutex; use pstr_core::appearance::{Accent, Appearance, Flavor, Palette}; +use pstr_core::browse::Sort; use pstr_core::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows}; use pstr_core::chapters::{Chapter, ChapterRole}; use pstr_core::config::AppDirs; use pstr_core::library::{Episode, Library, Title, TitleKind}; use pstr_core::metadata::{ - EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, + EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames, }; use pstr_core::prefs::PlaybackPrefs; use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType}; @@ -175,6 +176,55 @@ pub struct AppearanceRecord { pub flavor: FlavorChoice, pub accent: AccentChoice, pub gradients: bool, + /// Which name a title goes by. Shared with the desktop. + pub names: TitleNamesChoice, +} + +/// The shared `pstr_core::metadata::TitleNames`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum TitleNamesChoice { + /// The share's folder name. + Library, + English, + Romaji, +} + +/// The shared `pstr_core::browse::Sort`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum LibrarySort { + Name, + Recent, + Added, + Release, + Rating, + Popularity, +} + +/// One tile of the library grid. +#[derive(Debug, Clone, uniffi::Record)] +pub struct TileRecord { + /// The title drawn. + pub key: String, + /// How many other titles of its franchise it stands for. + pub folded: u32, +} + +/// One row of titles that share a director, studio or genre. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ShelfRecord { + pub label: String, + pub keys: Vec, +} + +/// The library grid, ordered and folded, and the shelves above it — what +/// `pstr_core::browse` and `pstr_core::shelves` make of the library, as keys +/// into [`AndroidEngine::library`]'s titles. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ArrangementRecord { + pub tiles: Vec, + /// Empty while searching: a shelf of titles the search did not find reads + /// as the search not working. + pub shelves: Vec, } /// One flavour and accent resolved into colours, packed as `0xAARRGGBB`. @@ -327,6 +377,26 @@ pub struct TitleRecord { pub external_url: Option, pub manual_match: bool, pub seasons: Vec, + /// The name to show, as the viewer's name setting has it. + pub display_name: String, + /// The widest art there is: fanart, else the provider's banner. + pub wide_url: Option, + /// `TV`, `Film`, `OVA` — the provider's format, said for a person. + pub format_label: Option, + /// `Spring 2013`. + pub season_label: Option, + pub studios: Vec, + pub directors: Vec, + pub tags: Vec, + /// Still airing. + pub airing: bool, + /// The next episode to air and when, Unix seconds — only while that is + /// still ahead. + pub next_episode: Option, + pub next_airing_at: Option, + /// Every title of its franchise, in release order, its own key included; + /// empty for a title that stands alone. + pub franchise: Vec, } /// Playback preferences that outlive one file, one title and one launch. @@ -952,23 +1022,70 @@ impl AndroidEngine { /// table scans, a `Library::build` and a full record conversion per /// keystroke. pub fn library(&self, search: Option) -> Result, BridgeError> { - let titles = self.all_title_records()?; - let needle = search.unwrap_or_default().trim().to_lowercase(); - if needle.is_empty() { - return Ok(titles); - } - Ok(titles - .into_iter() - .filter(|title| { - title.name.to_lowercase().contains(&needle) - || title.seasons.iter().any(|season| { - season - .episodes - .iter() - .any(|episode| episode.name.to_lowercase().contains(&needle)) + let query = search.unwrap_or_default(); + self.with_library(|cached| { + if query.trim().is_empty() { + return cached.titles.clone(); + } + let found: std::collections::HashSet<&str> = + pstr_core::browse::search(&cached.library, &cached.metadata, &query) + .into_iter() + .map(|title| title.key.as_str()) + .collect(); + cached + .titles + .iter() + .filter(|title| found.contains(title.key.as_str())) + .cloned() + .collect() + }) + } + + /// The library grid for `search`, in `sort` order, each franchise folded + /// into one tile when `grouped` — and the shelves above it. Keys into + /// [`AndroidEngine::library`]'s titles, so the records cross the bridge + /// once. + pub fn arrangement( + &self, + search: Option, + sort: LibrarySort, + grouped: bool, + ) -> Result { + let query = search.unwrap_or_default(); + self.with_library(|cached| { + let titles = pstr_core::browse::search(&cached.library, &cached.metadata, &query); + let tiles = pstr_core::browse::arrange( + &titles, + pstr_core::browse::Order { + sort: sort_choice(sort), + grouped, + names: cached.names, + metadata: &cached.metadata, + added: &cached.added, + }, + ); + let shelves = if query.trim().is_empty() { + pstr_core::shelves::pick(&cached.library.titles, &cached.metadata, SHELVES) + .into_iter() + .map(|shelf| ShelfRecord { + label: shelf.label, + keys: shelf.keys, }) - }) - .collect()) + .collect() + } else { + Vec::new() + }; + ArrangementRecord { + tiles: tiles + .iter() + .map(|tile| TileRecord { + key: titles[tile.lead].key.clone(), + folded: (tile.members.len() - 1) as u32, + }) + .collect(), + shelves, + } + }) } pub fn metadata_settings(&self) -> Result { @@ -1184,12 +1301,11 @@ impl AndroidEngine { } pub fn set_appearance(&self, appearance: AppearanceRecord) -> Result<(), BridgeError> { - // The record carries the palette and nothing else; what the desktop's - // library layout fields hold is kept as it was stored. + // The record has no grid shape — Android's is always covers — so the + // desktop's is kept as it was stored. let stored = pstr_core::appearance::load(&self.dirs).unwrap_or_default(); let appearance = Appearance { tiles: stored.tiles, - names: stored.names, ..appearance_choice(appearance) }; pstr_core::appearance::save(&self.dirs, &appearance).map_err(BridgeError::from_display) @@ -1518,13 +1634,19 @@ impl AndroidEngine { } /// Every title as a bridge record, rebuilt only when the catalog moved. - fn all_title_records(&self) -> Result, BridgeError> { + /// Run `read` over the library, its metadata and its records, rebuilt + /// only when the catalog or the name setting has changed since. + fn with_library(&self, read: impl FnOnce(&CachedLibrary) -> T) -> Result { + let names = pstr_core::appearance::load(&self.dirs) + .unwrap_or_default() + .names; let catalog = self.catalog.lock(); let writes = catalog.writes(); if let Some(cached) = self.library_cache.lock().as_ref() && cached.writes == writes + && cached.names == names { - return Ok(cached.titles.clone()); + return Ok(read(cached)); } let files = catalog.all_files().map_err(BridgeError::from_display)?; @@ -1538,26 +1660,61 @@ impl AndroidEngine { let offline = catalog .all_offline_files() .map_err(BridgeError::from_display)?; + let first_seen = catalog.first_seen().map_err(BridgeError::from_display)?; drop(catalog); let library = Library::build(files, &watch); + let all: Vec<&Title> = library.titles.iter().collect(); + let franchises: HashMap> = pstr_core::franchise::group(&all, &metadata) + .into_iter() + .filter(|franchise| franchise.members.len() > 1) + .flat_map(|franchise| { + let keys: Vec = franchise + .members + .iter() + .map(|&index| all[index].key.clone()) + .collect(); + keys.clone().into_iter().map(move |key| (key, keys.clone())) + }) + .collect(); + let now = now(); let titles: Vec = library .titles .iter() .map(|title| { - title_record( + let mut record = title_record( title, &offline, metadata.get(&title.key), guides.get(&title.key), - ) + ); + record.display_name = + pstr_core::browse::display_name(title, &metadata, names).to_owned(); + record.franchise = franchises.get(&title.key).cloned().unwrap_or_default(); + if let Some(details) = metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + .and_then(|found| found.details.as_ref()) + && let Some((episode, at)) = details.next_airing(now) + { + record.next_episode = Some(episode); + record.next_airing_at = Some(at); + } + record }) .collect(); - *self.library_cache.lock() = Some(CachedLibrary { + let added = library.added_at(&first_seen); + let cached = CachedLibrary { writes, - titles: titles.clone(), - }); - Ok(titles) + names, + library, + metadata, + added, + titles, + }; + let answer = read(&cached); + *self.library_cache.lock() = Some(cached); + Ok(answer) } fn titles_and_metadata( @@ -2316,6 +2473,11 @@ fn normalized_language(language: Option) -> Option { /// The library conversion, valid while the catalog has not been written to. struct CachedLibrary { writes: u64, + names: TitleNames, + library: Library, + metadata: HashMap, + /// When each title last gained a file. + added: HashMap, titles: Vec, } @@ -2370,9 +2532,29 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord { Accent::Red => AccentChoice::Red, }, gradients: appearance.gradients, + names: match appearance.names { + TitleNames::Library => TitleNamesChoice::Library, + TitleNames::English => TitleNamesChoice::English, + TitleNames::Romaji => TitleNamesChoice::Romaji, + }, } } +fn sort_choice(sort: LibrarySort) -> Sort { + match sort { + LibrarySort::Name => Sort::Name, + LibrarySort::Recent => Sort::Recent, + LibrarySort::Added => Sort::Added, + LibrarySort::Release => Sort::Release, + LibrarySort::Rating => Sort::Rating, + LibrarySort::Popularity => Sort::Popularity, + } +} + +/// How many shelves the library shows above the grid — fewer than the +/// desktop's four, because on a phone each is a screen's height. +const SHELVES: usize = 3; + fn appearance_choice(record: AppearanceRecord) -> Appearance { Appearance { flavor: match record.flavor { @@ -2395,6 +2577,11 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance { AccentChoice::Red => Accent::Red, }, gradients: record.gradients, + names: match record.names { + TitleNamesChoice::Library => TitleNames::Library, + TitleNamesChoice::English => TitleNames::English, + TitleNamesChoice::Romaji => TitleNames::Romaji, + }, ..Appearance::default() } } @@ -2469,6 +2656,7 @@ fn title_record( guide: Option<&EpisodeGuide>, ) -> TitleRecord { let metadata = record.and_then(|record| record.metadata.as_ref()); + let details = metadata.and_then(|found| found.details.as_ref()); TitleRecord { key: title.key.clone(), name: title.name.clone(), @@ -2493,6 +2681,17 @@ fn title_record( provider_episode_count: metadata.and_then(|metadata| metadata.episodes), external_url: metadata.and_then(|metadata| metadata.url.clone()), manual_match: record.is_some_and(|record| record.manual), + display_name: metadata.map_or_else(|| title.name.clone(), |found| found.name.clone()), + wide_url: metadata.and_then(|found| found.wide_art().map(str::to_owned)), + format_label: details.and_then(|details| details.format_label().map(str::to_owned)), + season_label: details.and_then(|details| details.season_label()), + studios: details.map_or_else(Vec::new, |details| details.studios.clone()), + directors: details.map_or_else(Vec::new, |details| details.directors.clone()), + tags: details.map_or_else(Vec::new, |details| details.tags.clone()), + airing: details.is_some_and(|details| details.is_airing()), + next_episode: None, + next_airing_at: None, + franchise: Vec::new(), seasons: title .seasons .iter() @@ -2641,6 +2840,7 @@ mod tests { flavor, accent: AccentChoice::Mauve, gradients: true, + names: super::TitleNamesChoice::English, } } @@ -2995,6 +3195,117 @@ mod tests { assert_eq!(pstr_android_stream_size(handle), -1); } + /// The arrangement folds a franchise into its series, and the name + /// setting — stored in the file the desktop reads — renames the tile + /// without anything in the catalog changing. + #[test] + fn the_library_folds_a_franchise_and_follows_the_name_setting() { + use pstr_core::catalog::CatalogNode; + use pstr_core::metadata::{MetadataRecord, TitleDetails}; + + let root = std::env::temp_dir().join(format!( + "pstr-android-arrangement-{}-{}", + std::process::id(), + super::now() + )); + let engine = AndroidEngine::new( + AndroidPaths { + config: root.join("config").to_string_lossy().into_owned(), + data: root.join("data").to_string_lossy().into_owned(), + cache: root.join("cache").to_string_lossy().into_owned(), + }, + Box::::default(), + ) + .expect("engine"); + + let file = |link: &str, name: &str| CatalogNode { + share_id: "s".into(), + link_id: link.into(), + volume_id: "v".into(), + parent_link_id: None, + name: name.into(), + is_folder: false, + media_type: None, + size: None, + active_revision_id: None, + parsed: pstr_core::naming::parse(name), + }; + let matched = |key: &str, id: &str, name: &str, kind, related: &str| MetadataRecord { + title_key: key.into(), + provider: ProviderId::AniList, + metadata: Some(TitleMetadata { + provider: ProviderId::AniList, + remote_id: id.into(), + name: name.into(), + original_name: None, + overview: None, + year: None, + kind, + poster_url: None, + backdrop_url: None, + rating: None, + genres: Vec::new(), + episodes: None, + url: None, + details: Some(TitleDetails { + related: vec![related.into()], + ..TitleDetails::default() + }), + }), + fetched_at: 0, + manual: false, + }; + { + let mut catalog = engine.catalog.lock(); + catalog + .replace_share( + "s", + &[ + file("a", "Sousou no Frieren S01E01.mkv"), + file("b", "Made in Abyss Dawn of the Deep Soul.mkv"), + ], + ) + .expect("crawl"); + catalog + .set_metadata(&matched( + "sousou no frieren", + "1", + "Frieren", + TitleKind::Series, + "2", + )) + .expect("match"); + catalog + .set_metadata(&matched( + "made in abyss dawn of the deep soul", + "2", + "Dawn of the Deep Soul", + TitleKind::Film, + "1", + )) + .expect("match"); + } + + let arranged = engine + .arrangement(None, super::LibrarySort::Name, true) + .expect("arrange"); + assert_eq!(arranged.tiles.len(), 1); + assert_eq!(arranged.tiles[0].key, "sousou no frieren"); + assert_eq!(arranged.tiles[0].folded, 1); + let titles = engine.library(Some("frieren".into())).expect("library"); + assert_eq!(titles.len(), 1); + assert_eq!(titles[0].display_name, "Frieren"); + assert_eq!(titles[0].franchise.len(), 2); + + let mut appearance = engine.appearance().expect("appearance"); + appearance.names = super::TitleNamesChoice::Library; + engine.set_appearance(appearance).expect("store"); + let titles = engine.library(Some("frieren".into())).expect("library"); + assert_eq!(titles[0].display_name, "Sousou no Frieren"); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn share_mutations_cleanup_even_when_secret_deletion_fails() { let unique = format!( diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 8abf93f..56343e6 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -382,45 +382,7 @@ impl Filter { } } -/// What order the grid is in. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Sort { - /// By the name shown, ignoring a leading article. - #[default] - Name, - /// Most recently played first. - Recent, - /// Most recently given a new file first. - Added, - /// Newest first by when it aired, undated last. - Release, - /// Best rated first, unrated last. - Rating, - /// Most followed at the provider first. - Popularity, -} - -impl Sort { - pub const ALL: [Self; 6] = [ - Self::Name, - Self::Recent, - Self::Added, - Self::Release, - Self::Rating, - Self::Popularity, - ]; - - pub fn label(self) -> &'static str { - match self { - Self::Name => "A – Z", - Self::Recent => "Recently watched", - Self::Added => "Recently added", - Self::Release => "Newest", - Self::Rating => "Highest rated", - Self::Popularity => "Most popular", - } - } -} +pub use pstr_core::browse::Sort; /// What the library view is built from, besides the library itself. pub struct ViewInputs<'a> { @@ -436,20 +398,13 @@ const SHELVES: usize = 4; impl LibraryView { fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) { - use pstr_core::library::{Title, sort_name}; + use pstr_core::library::Title; let built_with = (self.filter, self.sort, self.grouped, inputs.names); if !self.stale && self.query == inputs.query && self.built_with == Some(built_with) { return; } let metadata = inputs.metadata; - let found = |title: &Title| { - metadata - .get(&title.key) - .and_then(|record| record.metadata.as_ref()) - }; - let name = |title: &Title| ui::title_name(title, metadata, inputs.names).to_owned(); - if self.stale { let all: Vec<&Title> = library.titles.iter().collect(); self.franchises = pstr_core::franchise::group(&all, metadata) @@ -483,128 +438,41 @@ impl LibraryView { .collect(); } - // A search finds a title by the provider's names for it as well as - // the share's: a folder called `Sousou no Frieren` is found by typing - // "Frieren: Beyond". - let needle = inputs.query.trim().to_lowercase(); - let by_files: std::collections::HashSet<*const Title> = library - .search(inputs.query) + let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query) .into_iter() - .map(|title| title as *const Title) + .filter(|title| self.filter.admits(title)) .collect(); - let mut titles: Vec<(usize, &Title)> = library - .titles + let tiles = pstr_core::browse::arrange( + &titles, + pstr_core::browse::Order { + sort: self.sort, + grouped: self.grouped, + names: inputs.names, + metadata, + added: inputs.added, + }, + ); + let index_of = |title: &Title| { + library + .titles + .iter() + .position(|candidate| std::ptr::eq(candidate, title)) + }; + // Indices into what was arranged, made indices into the library. + let entries: Vec<(usize, usize)> = tiles .iter() - .enumerate() - .filter(|(_, title)| { - by_files.contains(&(*title as *const Title)) - || found(title).is_some_and(|found| { - std::iter::once(found.name.as_str()) - .chain(found.original_name.as_deref()) - .chain( - found - .details - .as_ref() - .and_then(|details| details.romaji.as_deref()), - ) - .any(|name| name.to_lowercase().contains(&needle)) - }) - }) - .filter(|(_, title)| self.filter.admits(title)) + .filter_map(|tile| Some((index_of(titles[tile.lead])?, tile.members.len()))) .collect(); - titles.sort_by_cached_key(|(_, title)| sort_name(&name(title))); - - // Each entry is the tile and every title it stands for. - let entries: Vec<(usize, Vec)> = if self.grouped { - let refs: Vec<&Title> = titles.iter().map(|(_, title)| *title).collect(); - pstr_core::franchise::group(&refs, metadata) - .into_iter() - .map(|franchise| { - let lead = titles[franchise.lead(&refs)].0; - let members = franchise - .members - .iter() - .map(|&member| titles[member].0) - .collect(); - (lead, members) - }) - .collect() - } else { - titles - .iter() - .map(|&(index, _)| (index, vec![index])) - .collect() - }; - let mut entries = entries; - let titles_of = |members: &[usize]| -> Vec<&Title> { - members - .iter() - .map(|&index| &library.titles[index]) - .collect() - }; - // Every sort is stable over the name order above, so ties and the - // undated, unrated and unplayed keep their alphabetical order. - match self.sort { - Sort::Name => {} - Sort::Recent => entries.sort_by_key(|(_, members)| { - std::cmp::Reverse( - titles_of(members) - .iter() - .map(|title| title.last_played()) - .max(), - ) - }), - Sort::Added => entries.sort_by_key(|(_, members)| { - std::cmp::Reverse( - titles_of(members) - .iter() - .filter_map(|title| inputs.added.get(&title.key)) - .max() - .copied(), - ) - }), - Sort::Release => entries.sort_by_cached_key(|(_, members)| { - std::cmp::Reverse( - titles_of(members) - .iter() - .map(|title| pstr_core::franchise::release_key(title, metadata)) - // `~` is how the key says "undated"; it sorts last - // here rather than first. - .filter(|key| key != "~") - .max(), - ) - }), - Sort::Rating => entries.sort_by_key(|(lead, _)| { - std::cmp::Reverse( - found(&library.titles[*lead]) - .and_then(|found| found.rating) - .map(|rating| (rating * 100.0) as i64), - ) - }), - Sort::Popularity => entries.sort_by_key(|(lead, _)| { - std::cmp::Reverse( - found(&library.titles[*lead]) - .and_then(|found| found.details.as_ref()) - .and_then(|details| details.popularity), - ) - }), - } self.folded = entries .iter() - .filter(|(_, members)| members.len() > 1) - .map(|(lead, members)| (*lead, members.len() - 1)) + .filter(|(_, members)| *members > 1) + .map(|(lead, members)| (*lead, members - 1)) .collect(); self.matches = entries.into_iter().map(|(lead, _)| lead).collect(); self.built_with = Some(built_with); if self.stale { self.history = ui::history::entries(library); - let index_of = |title: &Title| { - library - .titles - .iter() - .position(|candidate| std::ptr::eq(candidate, title)) - }; self.resumable = library .continue_watching() .into_iter() diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index 319017f..ebcaedf 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -44,19 +44,7 @@ pub struct Art<'a> { pub names: TitleNames, } -/// The name to show for `title`: the provider's, in the form the viewer -/// picked, else the share's. -pub fn title_name<'a>( - title: &'a Title, - metadata: &'a HashMap, - names: TitleNames, -) -> &'a str { - metadata - .get(&title.key) - .and_then(|record| record.metadata.as_ref()) - .and_then(|found| found.display_name(names)) - .unwrap_or(&title.name) -} +pub use pstr_core::browse::display_name as title_name; impl Art<'_> { /// What the provider says about one file of a title, matched on the diff --git a/crates/pstr-core/src/browse.rs b/crates/pstr-core/src/browse.rs new file mode 100644 index 0000000..4c2eefc --- /dev/null +++ b/crates/pstr-core/src/browse.rs @@ -0,0 +1,395 @@ +//! Finding and ordering titles: what both library grids show, and in what +//! order. +//! +//! Here rather than in a front end so the desktop and Android agree: the same +//! search finds the same titles, a franchise folds into the same tile, and +//! "Newest" means the same thing on both. + +use std::collections::{HashMap, HashSet}; + +use crate::library::{Library, Title, sort_name}; +use crate::metadata::{MetadataRecord, TitleNames}; + +/// What order a grid is in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Sort { + /// By the name shown, ignoring a leading article. + #[default] + Name, + /// Most recently played first. + Recent, + /// Most recently given a new file first. + Added, + /// Newest first by when it aired, undated last. + Release, + /// Best rated first, unrated last. + Rating, + /// Most followed at the provider first. + Popularity, +} + +impl Sort { + pub const ALL: [Self; 6] = [ + Self::Name, + Self::Recent, + Self::Added, + Self::Release, + Self::Rating, + Self::Popularity, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Name => "A – Z", + Self::Recent => "Recently watched", + Self::Added => "Recently added", + Self::Release => "Newest", + Self::Rating => "Highest rated", + Self::Popularity => "Most popular", + } + } +} + +/// The name to show for `title`: the provider's, in the form the viewer +/// picked, else the share's. +pub fn display_name<'a>( + title: &'a Title, + metadata: &'a HashMap, + names: TitleNames, +) -> &'a str { + metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + .and_then(|found| found.display_name(names)) + .unwrap_or(&title.name) +} + +/// The titles `query` finds, in library order. +/// +/// By the share's name and every filename under it — a release group, an +/// episode name — and by every name the provider gave: a folder called +/// `Sousou no Frieren` is found by typing "Frieren: Beyond". +pub fn search<'a>( + library: &'a Library, + metadata: &HashMap, + query: &str, +) -> Vec<&'a Title> { + let needle = query.trim().to_lowercase(); + let by_files: HashSet<*const Title> = library + .search(query) + .into_iter() + .map(|title| title as *const Title) + .collect(); + library + .titles + .iter() + .filter(|title| { + by_files.contains(&(*title as *const Title)) + || metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + .is_some_and(|found| { + std::iter::once(found.name.as_str()) + .chain(found.original_name.as_deref()) + .chain( + found + .details + .as_ref() + .and_then(|details| details.romaji.as_deref()), + ) + .any(|name| name.to_lowercase().contains(&needle)) + }) + }) + .collect() +} + +/// How to order a grid. +#[derive(Debug, Clone, Copy)] +pub struct Order<'a> { + pub sort: Sort, + /// One tile per franchise rather than one per title. + pub grouped: bool, + pub names: TitleNames, + pub metadata: &'a HashMap, + /// When each title last gained a file, by key — see + /// [`Library::added_at`]. + pub added: &'a HashMap, +} + +/// One tile: the title drawn, and every title it stands for — itself +/// included, in release order — as indices into what was arranged. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Tile { + pub lead: usize, + pub members: Vec, +} + +/// `titles` as tiles, in `order`. +/// +/// Every sort is stable over the name order, so ties — and the undated, +/// unrated and unplayed — keep their alphabetical order. +pub fn arrange<'t>(titles: &[&'t Title], order: Order<'_>) -> Vec { + let metadata = order.metadata; + let found = |title: &Title| { + metadata + .get(&title.key) + .and_then(|record| record.metadata.as_ref()) + }; + + let mut by_name: Vec = (0..titles.len()).collect(); + by_name + .sort_by_cached_key(|&index| sort_name(display_name(titles[index], metadata, order.names))); + let sorted: Vec<&Title> = by_name.iter().map(|&index| titles[index]).collect(); + + let mut tiles: Vec = if order.grouped { + crate::franchise::group(&sorted, metadata) + .into_iter() + .map(|franchise| Tile { + lead: by_name[franchise.lead(&sorted)], + members: franchise + .members + .iter() + .map(|&member| by_name[member]) + .collect(), + }) + .collect() + } else { + by_name + .iter() + .map(|&index| Tile { + lead: index, + members: vec![index], + }) + .collect() + }; + + // A folded franchise files under the name on its tile — the lead's — and + // not under whichever member sorted first: "Made in Abyss" belongs at M + // even when its film is called "Dawn of the Deep Soul". + tiles.sort_by_cached_key(|tile| { + sort_name(display_name(titles[tile.lead], metadata, order.names)) + }); + + let members = |tile: &Tile| -> Vec<&'t Title> { + tile.members.iter().map(|&index| titles[index]).collect() + }; + match order.sort { + Sort::Name => {} + Sort::Recent => tiles.sort_by_key(|tile| { + std::cmp::Reverse(members(tile).iter().map(|title| title.last_played()).max()) + }), + Sort::Added => tiles.sort_by_key(|tile| { + std::cmp::Reverse( + members(tile) + .iter() + .filter_map(|title| order.added.get(&title.key)) + .max() + .copied(), + ) + }), + Sort::Release => tiles.sort_by_cached_key(|tile| { + std::cmp::Reverse( + members(tile) + .iter() + .map(|title| crate::franchise::release_key(title, metadata)) + // `~` is how the key says "undated"; it sorts last here + // rather than first. + .filter(|key| key != "~") + .max(), + ) + }), + Sort::Rating => tiles.sort_by_key(|tile| { + std::cmp::Reverse( + found(titles[tile.lead]) + .and_then(|found| found.rating) + .map(|rating| (rating * 100.0) as i64), + ) + }), + Sort::Popularity => tiles.sort_by_key(|tile| { + std::cmp::Reverse( + found(titles[tile.lead]) + .and_then(|found| found.details.as_ref()) + .and_then(|details| details.popularity), + ) + }), + } + tiles +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::library::TitleKind; + use crate::metadata::{ProviderId, TitleDetails, TitleMetadata}; + + fn title(key: &str, name: &str, kind: TitleKind) -> Title { + Title { + key: key.into(), + name: name.into(), + year: None, + kind, + seasons: Vec::new(), + share_ids: Vec::new(), + } + } + + fn matched( + key: &str, + id: &str, + english: &str, + start: &str, + rating: f32, + related: &[&str], + ) -> (String, MetadataRecord) { + ( + key.into(), + MetadataRecord { + title_key: key.into(), + provider: ProviderId::AniList, + metadata: Some(TitleMetadata { + provider: ProviderId::AniList, + remote_id: id.into(), + name: english.into(), + original_name: None, + overview: None, + year: None, + kind: TitleKind::Series, + poster_url: None, + backdrop_url: None, + rating: Some(rating), + genres: Vec::new(), + episodes: None, + url: None, + details: Some(TitleDetails { + romaji: Some(key.into()), + start_date: Some(start.into()), + related: related.iter().map(|id| id.to_string()).collect(), + ..TitleDetails::default() + }), + }), + fetched_at: 0, + manual: false, + }, + ) + } + + fn fixture() -> (Vec, HashMap<String, MetadataRecord>) { + let titles = vec![ + title("abyss film", "Made in Abyss Dawn", TitleKind::Film), + title("made in abyss", "Made in Abyss", TitleKind::Series), + title("sousou no frieren", "Sousou no Frieren", TitleKind::Series), + ]; + let metadata = HashMap::from([ + matched( + "abyss film", + "2", + "Dawn of the Deep Soul", + "2020-01-17", + 8.5, + &["1"], + ), + matched( + "made in abyss", + "1", + "Made in Abyss", + "2017-07-07", + 8.4, + &["2"], + ), + matched( + "sousou no frieren", + "3", + "Frieren: Beyond Journey's End", + "2023-09-29", + 9.1, + &[], + ), + ]); + (titles, metadata) + } + + fn keys(tiles: &[Tile], titles: &[&Title]) -> Vec<String> { + tiles + .iter() + .map(|tile| titles[tile.lead].key.clone()) + .collect() + } + + #[test] + fn a_franchise_folds_into_its_series_and_sorts_by_the_name_shown() { + let (owned, metadata) = fixture(); + let titles: Vec<&Title> = owned.iter().collect(); + let added = HashMap::new(); + let order = Order { + sort: Sort::Name, + grouped: true, + names: TitleNames::English, + metadata: &metadata, + added: &added, + }; + let tiles = arrange(&titles, order); + // "Frieren" before "Made in Abyss" in English; the film is folded. + assert_eq!( + keys(&tiles, &titles), + vec!["sousou no frieren", "made in abyss"] + ); + assert_eq!(tiles[1].members, vec![1, 0]); + + let tiles = arrange( + &titles, + Order { + names: TitleNames::Library, + grouped: false, + ..order + }, + ); + assert_eq!( + keys(&tiles, &titles), + vec!["made in abyss", "abyss film", "sousou no frieren"] + ); + } + + #[test] + fn newest_and_best_rated_read_the_provider_and_put_the_unknown_last() { + let (mut owned, metadata) = fixture(); + owned.push(title( + "game of thrones", + "Game of Thrones", + TitleKind::Series, + )); + let titles: Vec<&Title> = owned.iter().collect(); + let added = HashMap::new(); + let order = Order { + sort: Sort::Release, + grouped: true, + names: TitleNames::Library, + metadata: &metadata, + added: &added, + }; + // The Made in Abyss franchise dates from its newest part, 2020. + assert_eq!( + keys(&arrange(&titles, order), &titles), + vec!["sousou no frieren", "made in abyss", "game of thrones"] + ); + let order = Order { + sort: Sort::Rating, + ..order + }; + assert_eq!( + keys(&arrange(&titles, order), &titles) + .last() + .map(String::as_str), + Some("game of thrones") + ); + } + + #[test] + fn search_finds_a_title_by_any_name_the_provider_gave_it() { + let (owned, metadata) = fixture(); + let library = Library { titles: owned }; + let found = search(&library, &metadata, "beyond journey"); + assert_eq!(found.len(), 1); + assert_eq!(found[0].key, "sousou no frieren"); + assert_eq!(search(&library, &metadata, "").len(), 3); + } +} diff --git a/crates/pstr-core/src/lib.rs b/crates/pstr-core/src/lib.rs index 634e920..0c9ba9b 100644 --- a/crates/pstr-core/src/lib.rs +++ b/crates/pstr-core/src/lib.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] pub mod appearance; +pub mod browse; pub mod catalog; pub mod chapters; pub mod config; diff --git a/crates/pstr-core/src/metadata.rs b/crates/pstr-core/src/metadata.rs index f052a13..b856a16 100644 --- a/crates/pstr-core/src/metadata.rs +++ b/crates/pstr-core/src/metadata.rs @@ -240,11 +240,13 @@ impl TitleMetadata { #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum TitleNames { - /// The folder's — what the share calls it. The default, because it is the - /// one name that is there whether or not anything matched. - #[default] + /// The folder's — what the share calls it. Library, - /// The provider's English title, where it has one. + /// The provider's English title, where it has one. The default, because + /// it is what Android always showed, and a folder name is whatever an + /// uploader typed — `Kaijuu 8-gou`, `Fate Stay Night - Heaven s Feel - + /// THE MOVIE I presage flower`. + #[default] English, /// The provider's romaji title. Romaji, diff --git a/docs/METADATA.md b/docs/METADATA.md index 10d93f6..c2d9edf 100644 --- a/docs/METADATA.md +++ b/docs/METADATA.md @@ -126,10 +126,11 @@ page's band. The banner, wrong in a 16:9 tile, fits those strips as drawn. or 16:9 stills. Covers fill their tile; a cover in a still-shaped tile is still fitted whole. -**Names.** Settings → Appearance → *Title names*: the share's, English or -romaji. Search matches all of them whichever is shown. +**Names.** Settings → Appearance → *Title names*: English (the default, and +what Android always showed), romaji, or the share's own. Search matches all +of them whichever is shown. -**Sorts.** A–Z by the shown name, recently watched, recently added (from +**Sorts.** `pstr_core::browse`, shared by both clients. A–Z by the shown name, recently watched, recently added (from `node_first_seen`, which survives recrawls; files present before v10 count as added at 0), newest by air date, highest rated, most popular. @@ -150,15 +151,19 @@ Satoshi Kon*, *From MADHOUSE*, *From MAPPA*, *From Production I.G*. **The title page** adds the season, studios, director and next airing episode to its line of facts, and the tags beside the genres. -Not done: Android shows none of this yet beyond what it already drew — the -bridge carries `TitleDetails` through a hand-picked match, and the grouping, -shelves and sorts are in `pstr-core` ready for it, but its screens are -unchanged. TMDB fills no details. +**Android.** The same, through the bridge: `AndroidEngine::arrangement` +returns the grid's tiles and the shelves as keys into `library`'s titles, and +each `TitleRecord` carries its display name, franchise and details. The +library's bar has a sort menu with the franchise toggle in it; the grid shows +`+N` on a folded tile and up to three shelves above it; the title page has the +same facts, tags and franchise row as the desktop's; *Title names* is in +Settings → Appearance. Android's grid is always covers, so it has no +*Library tiles* choice. + +Not done: TMDB fills no details. ## Phase 3 — reach -- **Android**: the sorts, grouping, shelves and names above. - - **Local matching** against the offline database, so only ids leave the machine. - **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for