feat(core,app,android): sort, fold and shelve the Android library

Bring the phase 2 library to Android. Search, sorting and franchise folding
move from the desktop app into pstr_core::browse, so both clients find,
fold and order the same titles the same way.

The bridge gains AndroidEngine::arrangement, which returns the grid's tiles
and up to three shelves as keys into the library's titles. Each TitleRecord
now carries its display name, wide art, format, season, studios, directors,
tags, airing state and franchise. The library bar has a sort menu with the
franchise toggle, folded tiles show a +N badge, and shelves sit above the
grid. The title page shows the same facts, tags and franchise row as the
desktop. Settings -> Appearance gains Title names.

The default title name is now English, which is what Android always showed.
A folded franchise now sorts by its lead's displayed name rather than by its
alphabetically first member.
This commit is contained in:
nvrl committed 2026-10-04 02:04:15 +02:00
1 parent aef182e7e8
commit 3638e0e5df
26 files changed
+1176 -250

No files matched your search

@@ -302,7 +302,7 @@ fun PlayerScreen(
NowPlaying( NowPlaying(
media = key, media = key,
title = episode.label, title = episode.label,
show = title.canonicalName ?: title.name, show = title.displayName,
detail = episode.detail, detail = episode.detail,
artworkUrl = title.posterUrl ?: title.backdropUrl, artworkUrl = title.posterUrl ?: title.backdropUrl,
artworkFile = episode.thumbnailSource, artworkFile = episode.thumbnailSource,
@@ -425,7 +425,7 @@ fun PlayerScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
Text( Text(
listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank)) listOfNotNull(title.displayName, episode.detail.takeIf(String::isNotBlank))
.joinToString(" • "), .joinToString(" • "),
color = Color.White.copy(alpha = 0.7f), color = Color.White.copy(alpha = 0.7f),
maxLines = 1, maxLines = 1,
@@ -25,6 +25,8 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import io.narl.protonstream.native.NativeRuntime 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.ShareRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.OfflineRecord
@@ -40,6 +42,14 @@ data class AppUiState(
val refreshing: Boolean = false, val refreshing: Boolean = false,
val query: String = "", val query: String = "",
val titles: List<TitleRecord> = emptyList(), val titles: List<TitleRecord> = 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<ShareRecord> = emptyList(), val shares: List<ShareRecord> = emptyList(),
val offline: List<OfflineRecord> = emptyList(), val offline: List<OfflineRecord> = emptyList(),
val metadataSettings: MetadataSettingsRecord = MetadataSettingsRecord( 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) { fun search(query: String) {
mutableState.update { it.copy(query = query) } mutableState.update { it.copy(query = query) }
searchQuery.value = query searchQuery.value = query
@@ -205,7 +221,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
val episodes = title.seasons.flatMap { it.episodes } val episodes = title.seasons.flatMap { it.episodes }
changeWatch( changeWatch(
episodes, episodes,
"${title.canonicalName ?: title.name} marked ${if (watched) "watched" else "unwatched"}", "${title.displayName} marked ${if (watched) "watched" else "unwatched"}",
) { engine, episode, before -> ) { engine, episode, before ->
val duration = before?.durationSecs val duration = before?.durationSecs
engine.saveWatchState( engine.saveWatchState(
@@ -225,7 +241,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) { fun forgetPosition(title: TitleRecord, episode: EpisodeRecord) {
changeWatch( changeWatch(
listOf(episode), listOf(episode),
"${title.canonicalName ?: title.name} removed from Continue watching", "${title.displayName} removed from Continue watching",
) { engine, target, before -> ) { engine, target, before ->
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false) 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) { fun removeFromHistory(title: TitleRecord, episode: EpisodeRecord) {
changeWatch( changeWatch(
listOf(episode), listOf(episode),
"${episode.label} of ${title.canonicalName ?: title.name} removed from history", "${episode.label} of ${title.displayName} removed from history",
) { engine, target, before -> ) { engine, target, before ->
engine.saveWatchState(target.shareId, target.linkId, 0.0, before?.durationSecs, false) 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 // and cached: a full reload is the one place that has
// already paid for a walk of the offline files. // already paid for a walk of the offline files.
engine.pruneOfflineFiles() engine.pruneOfflineFiles()
val current = mutableState.value
val query = current.query.takeIf(String::isNotBlank)
Reloaded( Reloaded(
engine.shares(), engine.shares(),
engine.library(mutableState.value.query.takeIf(String::isNotBlank)), engine.library(query),
engine.arrangement(query, current.sort, current.grouped),
engine.offlineFiles(), engine.offlineFiles(),
engine.metadataSettings(), engine.metadataSettings(),
engine.storageUsage(), engine.storageUsage(),
@@ -457,6 +476,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
loading = false, loading = false,
shares = result.shares, shares = result.shares,
titles = result.titles, titles = result.titles,
arrangement = result.arrangement,
offline = result.offline, offline = result.offline,
metadataSettings = result.metadataSettings, metadataSettings = result.metadataSettings,
storage = result.storage, storage = result.storage,
@@ -469,10 +489,16 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie
} }
private suspend fun reloadLibrary(query: String) { private suspend fun reloadLibrary(query: String) {
val current = mutableState.value
runCatching { runCatching {
withContext(Dispatchers.IO) { NativeRuntime.engine().library(query.takeIf(String::isNotBlank)) } withContext(Dispatchers.IO) {
}.onSuccess { titles -> mutableState.update { it.copy(titles = titles) } } val engine = NativeRuntime.engine()
.onFailure { error -> mutableState.update { it.copy(message = error.message) } } 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 { 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( private data class Reloaded(
val shares: List<ShareRecord>, val shares: List<ShareRecord>,
val titles: List<TitleRecord>, val titles: List<TitleRecord>,
val arrangement: ArrangementRecord,
val offline: List<OfflineRecord>, val offline: List<OfflineRecord>,
val metadataSettings: MetadataSettingsRecord, val metadataSettings: MetadataSettingsRecord,
val storage: StorageUsageRecord, val storage: StorageUsageRecord,
@@ -74,7 +74,7 @@ internal fun DownloadsScreen(
state.offline state.offline
.groupBy { shows["${it.shareId}/${it.linkId}"] } .groupBy { shows["${it.shareId}/${it.linkId}"] }
.map { (title, files) -> .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 } .sortedBy { it.name }
} }
@@ -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)) { Column(Modifier.weight(1f).clickable(onClick = onTitle).padding(horizontal = 12.dp, vertical = 4.dp)) {
Text( Text(
entry.title.canonicalName ?: entry.title.name, entry.title.displayName,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -76,7 +76,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentProgress 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.EpisodeRecord
import uniffi.pstr_android.LibrarySort
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType 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. */ /** 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) 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<TitleRecord>)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun LibraryScreen( internal fun LibraryScreen(
@@ -105,6 +117,7 @@ internal fun LibraryScreen(
padding: PaddingValues, padding: PaddingValues,
onMatchChanged: () -> Unit = {}, onMatchChanged: () -> Unit = {},
onError: (Throwable) -> Unit = {}, onError: (Throwable) -> Unit = {},
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
) { ) {
val context = LocalContext.current val context = LocalContext.current
// What a long press opened a menu for: a title, or a Continue watching card. // 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 }) { IconButton(onClick = { searching = true }) {
Icon(Icons.Default.Search, contentDescription = "Search library") Icon(Icons.Default.Search, contentDescription = "Search library")
} }
ArrangeMenu(state.sort, state.grouped, onArrange)
// Pull to refresh is the gesture; the icon stays for a // Pull to refresh is the gesture; the icon stays for a
// viewer who does not know it. // viewer who does not know it.
IconButton(onClick = onRefresh, enabled = !state.refreshing) { IconButton(onClick = onRefresh, enabled = !state.refreshing) {
@@ -183,7 +197,7 @@ internal fun LibraryScreen(
menuFor?.let { title -> menuFor?.let { title ->
val playlist = title.seasons.flatMap(SeasonRecord::episodes) val playlist = title.seasons.flatMap(SeasonRecord::episodes)
val allWatched = title.episodeCount > 0uL && title.watchedCount == title.episodeCount 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") { MenuRow(Icons.Default.PlayArrow, if (playlist.any { it.resumeAt != null }) "Resume" else "Play") {
onResume(title, nextUpIndex(playlist)) onResume(title, nextUpIndex(playlist))
} }
@@ -211,7 +225,7 @@ internal fun LibraryScreen(
} }
menuResume?.let { entry -> menuResume?.let { entry ->
TileMenu( TileMenu(
entry.title.canonicalName ?: entry.title.name, entry.title.displayName,
entry.episode.label, entry.episode.label,
onDismiss = { menuResume = null }, 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 * 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. * 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, onTitleMenu: (TitleRecord) -> Unit,
onResumeMenu: (Resumable) -> 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 // 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. // two titles to a screen, which is a library read one row at a time.
LazyVerticalGrid( 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 -> items(tiles, key = { it.title.key }) { tile ->
PosterTile(title, onClick = { onTitle(title) }, onLongClick = { onTitleMenu(title) }) 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. */ /** Enough placeholder tiles to fill a tablet's first screen. */
private const val SKELETON_TILES = 18 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)) { Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(MaterialTheme.shapes.large)) {
RemoteArtwork( RemoteArtwork(
title.backdropUrl ?: title.posterUrl, title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name, title.displayName,
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
fallback = title.thumbnailSource, fallback = title.thumbnailSource,
labelled = false, labelled = false,
@@ -433,7 +542,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
) )
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) { Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
Text( Text(
title.canonicalName ?: title.name, title.displayName,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = Color.White, color = Color.White,
maxLines = 2, maxLines = 2,
@@ -481,7 +590,7 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) { Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
RemoteArtwork( RemoteArtwork(
entry.episode.stillUrl ?: entry.title.backdropUrl, entry.episode.stillUrl ?: entry.title.backdropUrl,
entry.title.canonicalName ?: entry.title.name, entry.title.displayName,
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
fallback = entry.episode.thumbnailSource, fallback = entry.episode.thumbnailSource,
) )
@@ -493,7 +602,7 @@ private fun ContinueCard(entry: Resumable, onClick: () -> Unit, onLongClick: ()
} }
} }
Text( Text(
entry.title.canonicalName ?: entry.title.name, entry.title.displayName,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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 @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( Column(
Modifier modifier
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.combinedClickable(onClick = onClick, onLongClick = onLongClick), .combinedClickable(onClick = onClick, onLongClick = onLongClick),
) { ) {
RemoteArtwork( Box {
title.posterUrl ?: title.backdropUrl, RemoteArtwork(
title.canonicalName ?: title.name, title.posterUrl ?: title.backdropUrl,
Modifier.sharedArt(title.key).fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium), title.displayName,
fallback = title.thumbnailSource, (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)) Spacer(Modifier.height(6.dp))
Text( Text(
title.canonicalName ?: title.name, title.displayName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 2, maxLines = 2,
@@ -215,6 +215,7 @@ fun ProtonStreamApp(
body, body,
onMatchChanged = model::reloadAfterMetadataChange, onMatchChanged = model::reloadAfterMetadataChange,
onError = model::reportError, onError = model::reportError,
onArrange = model::arrange,
) )
} }
val titlePane = @Composable { selected: TitleRecord -> val titlePane = @Composable { selected: TitleRecord ->
@@ -231,6 +232,10 @@ fun ProtonStreamApp(
model::reloadAfterMetadataChange, model::reloadAfterMetadataChange,
model::setWatched, model::setWatched,
body, body,
franchise = selected.franchise.mapNotNull { key ->
state.titles.firstOrNull { it.key == key }
},
onOpenTitle = { selectedTitleKey = it.key },
) )
} }
if (maxWidth >= TWO_PANE_WIDTH) { if (maxWidth >= TWO_PANE_WIDTH) {
@@ -292,6 +297,7 @@ fun ProtonStreamApp(
model::clearBlockCache, model::clearBlockCache,
model::removeAllOffline, model::removeAllOffline,
body, body,
onNamesChanged = model::reloadAfterMetadataChange,
) )
} }
} }
@@ -71,6 +71,7 @@ import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.MetadataProvider import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.FlavorChoice import uniffi.pstr_android.FlavorChoice
import uniffi.pstr_android.AppearanceRecord import uniffi.pstr_android.AppearanceRecord
import uniffi.pstr_android.TitleNamesChoice
import uniffi.pstr_android.AccentChoice import uniffi.pstr_android.AccentChoice
import uniffi.pstr_android.PlaybackPrefsRecord import uniffi.pstr_android.PlaybackPrefsRecord
import androidx.compose.material3.ListItem 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, // A parameter so the screenshot tests can pin it: read from the package,
// every version bump changed the committed image of this page. // every version bump changed the committed image of this page.
version: String = appVersion(), 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 context = LocalContext.current
val settings = remember { SettingsStore(context) } val settings = remember { SettingsStore(context) }
@@ -264,7 +267,7 @@ internal fun SettingsScreen(
) )
} }
SettingsPage.Appearance -> SettingsSubPage(SettingsPage.Appearance, padding, back) { 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) { SettingsPage.Metadata -> SettingsSubPage(SettingsPage.Metadata, padding, back) {
SettingAction( SettingAction(
@@ -502,7 +505,7 @@ internal fun SettingToggle(label: String, checked: Boolean, onChecked: (Boolean)
* the file both clients read. * the file both clients read.
*/ */
@Composable @Composable
private fun AppearancePicker() { private fun AppearancePicker(onNamesChanged: () -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var choice by remember { mutableStateOf<AppearanceRecord?>(null) } var choice by remember { mutableStateOf<AppearanceRecord?>(null) }
var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(emptyMap()) } var swatches by remember { mutableStateOf<Map<AccentChoice, Color>>(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 -> SettingToggle("Paint the accent as a gradient", current.gradients) { on ->
scope.launch { repaint(current.copy(gradients = on), store = true) } 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. */ /** What each palette family is called. The desktop client says the same. */
private fun FlavorChoice.label() = when (this) { private fun FlavorChoice.label() = when (this) {
FlavorChoice.PROTON -> "Proton" FlavorChoice.PROTON -> "Proton"
@@ -73,7 +73,9 @@ import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton import io.narl.protonstream.ui.theme.QuietButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.theme.solid import io.narl.protonstream.ui.theme.solid
import androidx.compose.foundation.border
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.MatchRecord import uniffi.pstr_android.MatchRecord
@@ -95,6 +97,9 @@ internal fun TitleScreen(
onMetadataChanged: () -> Unit, onMetadataChanged: () -> Unit,
onSetWatched: (EpisodeRecord, Boolean) -> Unit, onSetWatched: (EpisodeRecord, Boolean) -> Unit,
padding: PaddingValues, padding: PaddingValues,
/** Every title of its franchise, in release order, this one included. */
franchise: List<TitleRecord> = emptyList(),
onOpenTitle: (TitleRecord) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
var showMatch by remember(title.key) { mutableStateOf(false) } var showMatch by remember(title.key) { mutableStateOf(false) }
@@ -165,7 +170,7 @@ internal fun TitleScreen(
Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) { Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) {
RemoteArtwork( RemoteArtwork(
title.backdropUrl ?: title.posterUrl, title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name, title.displayName,
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
fallback = title.thumbnailSource, fallback = title.thumbnailSource,
labelled = false, labelled = false,
@@ -192,21 +197,40 @@ internal fun TitleScreen(
} }
item(key = "heading") { item(key = "heading") {
Column(Modifier.padding(horizontal = side + 16.dp)) { Column(Modifier.padding(horizontal = side + 16.dp)) {
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium) Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
title.originalName?.takeIf { it != title.canonicalName }?.let { // 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(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
Text( Text(
listOfNotNull( listOfNotNull(
(title.metadataYear ?: title.year)?.toString(), title.formatLabel,
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
title.rating?.let { "★ %.1f".format(it) }, title.rating?.let { "★ %.1f".format(it) },
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL }, "${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
).joinToString(" · "), ).joinToString(" · "),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp), 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 // The one thing the page is for, full width and naming what it
// will play: "Resume" alone left the viewer guessing which // will play: "Resume" alone left the viewer guessing which
// episode of forty it meant. // episode of forty it meant.
@@ -271,6 +295,17 @@ internal fun TitleScreen(
.clickable { overviewOpen = !overviewOpen }, .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 // Said plainly, because it changes what a re-match will do: a
// hand-picked match is not overwritten by an automatic pass. // hand-picked match is not overwritten by an automatic pass.
if (title.manualMatch) { 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") { item(key = "seasons") {
Row( Row(
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp), 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. */ /** How wide the page's text and controls run on a window wider than a phone. */
private val READABLE_WIDTH = 720.dp private val READABLE_WIDTH = 720.dp
@@ -23,6 +23,9 @@ class HistoryTest {
metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null, metadataProvider = null, metadataId = null, metadataYear = null, metadataKind = null, posterUrl = null,
backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null, backdropUrl = null, rating = null, genres = emptyList(), providerEpisodeCount = null, externalUrl = null,
manualMatch = false, seasons = listOf(SeasonRecord(1u, "Season 1", episodes.toList())), 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 @Test
@@ -25,12 +25,15 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.PaletteRecord import uniffi.pstr_android.PaletteRecord
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.ShelfRecord
import uniffi.pstr_android.StorageUsageRecord import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.TileRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType import uniffi.pstr_android.TitleType
@@ -129,7 +132,7 @@ class ScreenshotTest {
@Test @Test
fun `a title with seasons`() = capture("title-phone") { fun `a title with seasons`() = capture("title-phone") {
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues()) TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues(), franchise = listOf(SERIES, SEQUEL))
} }
@Test @Test
@@ -250,6 +253,17 @@ class ScreenshotTest {
externalUrl = null, externalUrl = null,
manualMatch = false, manualMatch = false,
seasons = seasons, 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( 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) }), 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.", 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))))) val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
.copy(kind = TitleType.FILM, year = 1988u) .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("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) }))), 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( shares = listOf(
ShareRecord("anime", "anime", true), ShareRecord("anime", "anime", true),
ShareRecord("films", "films", false), ShareRecord("films", "films", false),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 KiB

After

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 781 KiB

After

Width:  |  Height:  |  Size: 769 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 KiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 649 KiB

After

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+339 -28
View File
@@ -11,12 +11,13 @@ use std::sync::{Arc, OnceLock};
use parking_lot::Mutex; use parking_lot::Mutex;
use pstr_core::appearance::{Accent, Appearance, Flavor, Palette}; 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::catalog::{Catalog, OfflineFile, TitleTrackPrefs, WatchState, build_rows};
use pstr_core::chapters::{Chapter, ChapterRole}; use pstr_core::chapters::{Chapter, ChapterRole};
use pstr_core::config::AppDirs; use pstr_core::config::AppDirs;
use pstr_core::library::{Episode, Library, Title, TitleKind}; use pstr_core::library::{Episode, Library, Title, TitleKind};
use pstr_core::metadata::{ use pstr_core::metadata::{
EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata, TitleNames,
}; };
use pstr_core::prefs::PlaybackPrefs; use pstr_core::prefs::PlaybackPrefs;
use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType}; use pstr_core::proton_drive_rs::{ProtonDrivePublicLinkClient, ThumbnailType};
@@ -175,6 +176,55 @@ pub struct AppearanceRecord {
pub flavor: FlavorChoice, pub flavor: FlavorChoice,
pub accent: AccentChoice, pub accent: AccentChoice,
pub gradients: bool, 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<String>,
}
/// 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<TileRecord>,
/// Empty while searching: a shelf of titles the search did not find reads
/// as the search not working.
pub shelves: Vec<ShelfRecord>,
} }
/// One flavour and accent resolved into colours, packed as `0xAARRGGBB`. /// One flavour and accent resolved into colours, packed as `0xAARRGGBB`.
@@ -327,6 +377,26 @@ pub struct TitleRecord {
pub external_url: Option<String>, pub external_url: Option<String>,
pub manual_match: bool, pub manual_match: bool,
pub seasons: Vec<SeasonRecord>, pub seasons: Vec<SeasonRecord>,
/// 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<String>,
/// `TV`, `Film`, `OVA` — the provider's format, said for a person.
pub format_label: Option<String>,
/// `Spring 2013`.
pub season_label: Option<String>,
pub studios: Vec<String>,
pub directors: Vec<String>,
pub tags: Vec<String>,
/// Still airing.
pub airing: bool,
/// The next episode to air and when, Unix seconds — only while that is
/// still ahead.
pub next_episode: Option<u32>,
pub next_airing_at: Option<i64>,
/// Every title of its franchise, in release order, its own key included;
/// empty for a title that stands alone.
pub franchise: Vec<String>,
} }
/// Playback preferences that outlive one file, one title and one launch. /// 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 /// table scans, a `Library::build` and a full record conversion per
/// keystroke. /// keystroke.
pub fn library(&self, search: Option<String>) -> Result<Vec<TitleRecord>, BridgeError> { pub fn library(&self, search: Option<String>) -> Result<Vec<TitleRecord>, BridgeError> {
let titles = self.all_title_records()?; let query = search.unwrap_or_default();
let needle = search.unwrap_or_default().trim().to_lowercase(); self.with_library(|cached| {
if needle.is_empty() { if query.trim().is_empty() {
return Ok(titles); return cached.titles.clone();
} }
Ok(titles let found: std::collections::HashSet<&str> =
.into_iter() pstr_core::browse::search(&cached.library, &cached.metadata, &query)
.filter(|title| { .into_iter()
title.name.to_lowercase().contains(&needle) .map(|title| title.key.as_str())
|| title.seasons.iter().any(|season| { .collect();
season cached
.episodes .titles
.iter() .iter()
.any(|episode| episode.name.to_lowercase().contains(&needle)) .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<String>,
sort: LibrarySort,
grouped: bool,
) -> Result<ArrangementRecord, BridgeError> {
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<MetadataSettingsRecord, BridgeError> { pub fn metadata_settings(&self) -> Result<MetadataSettingsRecord, BridgeError> {
@@ -1184,12 +1301,11 @@ impl AndroidEngine {
} }
pub fn set_appearance(&self, appearance: AppearanceRecord) -> Result<(), BridgeError> { pub fn set_appearance(&self, appearance: AppearanceRecord) -> Result<(), BridgeError> {
// The record carries the palette and nothing else; what the desktop's // The record has no grid shape — Android's is always covers — so the
// library layout fields hold is kept as it was stored. // desktop's is kept as it was stored.
let stored = pstr_core::appearance::load(&self.dirs).unwrap_or_default(); let stored = pstr_core::appearance::load(&self.dirs).unwrap_or_default();
let appearance = Appearance { let appearance = Appearance {
tiles: stored.tiles, tiles: stored.tiles,
names: stored.names,
..appearance_choice(appearance) ..appearance_choice(appearance)
}; };
pstr_core::appearance::save(&self.dirs, &appearance).map_err(BridgeError::from_display) 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. /// Every title as a bridge record, rebuilt only when the catalog moved.
fn all_title_records(&self) -> Result<Vec<TitleRecord>, 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<T>(&self, read: impl FnOnce(&CachedLibrary) -> T) -> Result<T, BridgeError> {
let names = pstr_core::appearance::load(&self.dirs)
.unwrap_or_default()
.names;
let catalog = self.catalog.lock(); let catalog = self.catalog.lock();
let writes = catalog.writes(); let writes = catalog.writes();
if let Some(cached) = self.library_cache.lock().as_ref() if let Some(cached) = self.library_cache.lock().as_ref()
&& cached.writes == writes && 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)?; let files = catalog.all_files().map_err(BridgeError::from_display)?;
@@ -1538,26 +1660,61 @@ impl AndroidEngine {
let offline = catalog let offline = catalog
.all_offline_files() .all_offline_files()
.map_err(BridgeError::from_display)?; .map_err(BridgeError::from_display)?;
let first_seen = catalog.first_seen().map_err(BridgeError::from_display)?;
drop(catalog); drop(catalog);
let library = Library::build(files, &watch); let library = Library::build(files, &watch);
let all: Vec<&Title> = library.titles.iter().collect();
let franchises: HashMap<String, Vec<String>> = pstr_core::franchise::group(&all, &metadata)
.into_iter()
.filter(|franchise| franchise.members.len() > 1)
.flat_map(|franchise| {
let keys: Vec<String> = 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<TitleRecord> = library let titles: Vec<TitleRecord> = library
.titles .titles
.iter() .iter()
.map(|title| { .map(|title| {
title_record( let mut record = title_record(
title, title,
&offline, &offline,
metadata.get(&title.key), metadata.get(&title.key),
guides.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(); .collect();
*self.library_cache.lock() = Some(CachedLibrary { let added = library.added_at(&first_seen);
let cached = CachedLibrary {
writes, writes,
titles: titles.clone(), names,
}); library,
Ok(titles) metadata,
added,
titles,
};
let answer = read(&cached);
*self.library_cache.lock() = Some(cached);
Ok(answer)
} }
fn titles_and_metadata( fn titles_and_metadata(
@@ -2316,6 +2473,11 @@ fn normalized_language(language: Option<String>) -> Option<String> {
/// The library conversion, valid while the catalog has not been written to. /// The library conversion, valid while the catalog has not been written to.
struct CachedLibrary { struct CachedLibrary {
writes: u64, writes: u64,
names: TitleNames,
library: Library,
metadata: HashMap<String, MetadataRecord>,
/// When each title last gained a file.
added: HashMap<String, i64>,
titles: Vec<TitleRecord>, titles: Vec<TitleRecord>,
} }
@@ -2370,9 +2532,29 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord {
Accent::Red => AccentChoice::Red, Accent::Red => AccentChoice::Red,
}, },
gradients: appearance.gradients, 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 { fn appearance_choice(record: AppearanceRecord) -> Appearance {
Appearance { Appearance {
flavor: match record.flavor { flavor: match record.flavor {
@@ -2395,6 +2577,11 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance {
AccentChoice::Red => Accent::Red, AccentChoice::Red => Accent::Red,
}, },
gradients: record.gradients, gradients: record.gradients,
names: match record.names {
TitleNamesChoice::Library => TitleNames::Library,
TitleNamesChoice::English => TitleNames::English,
TitleNamesChoice::Romaji => TitleNames::Romaji,
},
..Appearance::default() ..Appearance::default()
} }
} }
@@ -2469,6 +2656,7 @@ fn title_record(
guide: Option<&EpisodeGuide>, guide: Option<&EpisodeGuide>,
) -> TitleRecord { ) -> TitleRecord {
let metadata = record.and_then(|record| record.metadata.as_ref()); let metadata = record.and_then(|record| record.metadata.as_ref());
let details = metadata.and_then(|found| found.details.as_ref());
TitleRecord { TitleRecord {
key: title.key.clone(), key: title.key.clone(),
name: title.name.clone(), name: title.name.clone(),
@@ -2493,6 +2681,17 @@ fn title_record(
provider_episode_count: metadata.and_then(|metadata| metadata.episodes), provider_episode_count: metadata.and_then(|metadata| metadata.episodes),
external_url: metadata.and_then(|metadata| metadata.url.clone()), external_url: metadata.and_then(|metadata| metadata.url.clone()),
manual_match: record.is_some_and(|record| record.manual), 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: title
.seasons .seasons
.iter() .iter()
@@ -2641,6 +2840,7 @@ mod tests {
flavor, flavor,
accent: AccentChoice::Mauve, accent: AccentChoice::Mauve,
gradients: true, gradients: true,
names: super::TitleNamesChoice::English,
} }
} }
@@ -2995,6 +3195,117 @@ mod tests {
assert_eq!(pstr_android_stream_size(handle), -1); 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::<MemorySecrets>::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] #[test]
fn share_mutations_cleanup_even_when_secret_deletion_fails() { fn share_mutations_cleanup_even_when_secret_deletion_fails() {
let unique = format!( let unique = format!(
+25 -157
View File
@@ -382,45 +382,7 @@ impl Filter {
} }
} }
/// What order the grid is in. pub use pstr_core::browse::Sort;
#[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",
}
}
}
/// What the library view is built from, besides the library itself. /// What the library view is built from, besides the library itself.
pub struct ViewInputs<'a> { pub struct ViewInputs<'a> {
@@ -436,20 +398,13 @@ const SHELVES: usize = 4;
impl LibraryView { impl LibraryView {
fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) { 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); let built_with = (self.filter, self.sort, self.grouped, inputs.names);
if !self.stale && self.query == inputs.query && self.built_with == Some(built_with) { if !self.stale && self.query == inputs.query && self.built_with == Some(built_with) {
return; return;
} }
let metadata = inputs.metadata; 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 { if self.stale {
let all: Vec<&Title> = library.titles.iter().collect(); let all: Vec<&Title> = library.titles.iter().collect();
self.franchises = pstr_core::franchise::group(&all, metadata) self.franchises = pstr_core::franchise::group(&all, metadata)
@@ -483,128 +438,41 @@ impl LibraryView {
.collect(); .collect();
} }
// A search finds a title by the provider's names for it as well as let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query)
// 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)
.into_iter() .into_iter()
.map(|title| title as *const Title) .filter(|title| self.filter.admits(title))
.collect(); .collect();
let mut titles: Vec<(usize, &Title)> = library let tiles = pstr_core::browse::arrange(
.titles &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() .iter()
.enumerate() .filter_map(|tile| Some((index_of(titles[tile.lead])?, tile.members.len())))
.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))
.collect(); .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<usize>)> = 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 self.folded = entries
.iter() .iter()
.filter(|(_, members)| members.len() > 1) .filter(|(_, members)| *members > 1)
.map(|(lead, members)| (*lead, members.len() - 1)) .map(|(lead, members)| (*lead, members - 1))
.collect(); .collect();
self.matches = entries.into_iter().map(|(lead, _)| lead).collect(); self.matches = entries.into_iter().map(|(lead, _)| lead).collect();
self.built_with = Some(built_with); self.built_with = Some(built_with);
if self.stale { if self.stale {
self.history = ui::history::entries(library); self.history = ui::history::entries(library);
let index_of = |title: &Title| {
library
.titles
.iter()
.position(|candidate| std::ptr::eq(candidate, title))
};
self.resumable = library self.resumable = library
.continue_watching() .continue_watching()
.into_iter() .into_iter()
+1 -13
View File
@@ -44,19 +44,7 @@ pub struct Art<'a> {
pub names: TitleNames, pub names: TitleNames,
} }
/// The name to show for `title`: the provider's, in the form the viewer pub use pstr_core::browse::display_name as title_name;
/// picked, else the share's.
pub fn title_name<'a>(
title: &'a Title,
metadata: &'a HashMap<String, MetadataRecord>,
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)
}
impl Art<'_> { impl Art<'_> {
/// What the provider says about one file of a title, matched on the /// What the provider says about one file of a title, matched on the
+395
View File
@@ -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<String, MetadataRecord>,
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<String, MetadataRecord>,
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<String, MetadataRecord>,
/// When each title last gained a file, by key — see
/// [`Library::added_at`].
pub added: &'a HashMap<String, i64>,
}
/// 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<usize>,
}
/// `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<Tile> {
let metadata = order.metadata;
let found = |title: &Title| {
metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
};
let mut by_name: Vec<usize> = (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<Tile> = 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<Title>, 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);
}
}
+1
View File
@@ -6,6 +6,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod appearance; pub mod appearance;
pub mod browse;
pub mod catalog; pub mod catalog;
pub mod chapters; pub mod chapters;
pub mod config; pub mod config;
+6 -4
View File
@@ -240,11 +240,13 @@ impl TitleMetadata {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum TitleNames { pub enum TitleNames {
/// The folder's — what the share calls it. The default, because it is the /// The folder's — what the share calls it.
/// one name that is there whether or not anything matched.
#[default]
Library, 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, English,
/// The provider's romaji title. /// The provider's romaji title.
Romaji, Romaji,
+14 -9
View File
@@ -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 or 16:9 stills. Covers fill their tile; a cover in a still-shaped tile is still
fitted whole. fitted whole.
**Names.** Settings → Appearance → *Title names*: the share's, English or **Names.** Settings → Appearance → *Title names*: English (the default, and
romaji. Search matches all of them whichever is shown. 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 `node_first_seen`, which survives recrawls; files present before v10 count as
added at 0), newest by air date, highest rated, most popular. 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 **The title page** adds the season, studios, director and next airing episode
to its line of facts, and the tags beside the genres. 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 **Android.** The same, through the bridge: `AndroidEngine::arrangement`
bridge carries `TitleDetails` through a hand-picked match, and the grouping, returns the grid's tiles and the shelves as keys into `library`'s titles, and
shelves and sorts are in `pstr-core` ready for it, but its screens are each `TitleRecord` carries its display name, franchise and details. The
unchanged. TMDB fills no details. 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 ## Phase 3 — reach
- **Android**: the sorts, grouping, shelves and names above.
- **Local matching** against the offline database, so only ids leave the - **Local matching** against the offline database, so only ids leave the
machine. machine.
- **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for - **Routing by title**: AniList first, then TMDB (with a key) or TVmaze for