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.
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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<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 offline: List<OfflineRecord> = 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<ShareRecord>,
|
||||
val titles: List<TitleRecord>,
|
||||
val arrangement: ArrangementRecord,
|
||||
val offline: List<OfflineRecord>,
|
||||
val metadataSettings: MetadataSettingsRecord,
|
||||
val storage: StorageUsageRecord,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TitleRecord>)
|
||||
|
||||
@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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppearanceRecord?>(null) }
|
||||
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 ->
|
||||
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"
|
||||
|
||||
@@ -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<TitleRecord> = 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 814 KiB After Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 781 KiB After Width: | Height: | Size: 769 KiB |
|
Before Width: | Height: | Size: 821 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 628 KiB After Width: | Height: | Size: 638 KiB |
|
Before Width: | Height: | Size: 649 KiB After Width: | Height: | Size: 678 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |