feat(android,ios): offer posters and synopses on the library, as the desktop does

The offer appears when metadata is off and there is a library to put
posters on. It says what turning metadata on sends, links to Settings
and never turns anything on itself. "Not now" is stored on the device.
The existing library screenshots now run with metadata on, as their
shelf implies, and a new screenshot covers the offer.
This commit is contained in:
nvrl committed 2026-10-13 15:15:28 +02:00
1 parent a79e41aa8a
commit 6ddbcea429
8 files changed
+113 -4

No files matched your search

@@ -41,6 +41,11 @@ class SettingsStore(context: Context) {
get() = preferences.getString(KEY_LIBRARY_SORT, null)
set(value) { preferences.edit { putString(KEY_LIBRARY_SORT, value) } }
/** The library's offer of posters and synopses was turned down. */
var metadataOfferDismissed: Boolean
get() = preferences.getBoolean(KEY_METADATA_OFFER_DISMISSED, false)
set(value) { preferences.edit { putBoolean(KEY_METADATA_OFFER_DISMISSED, value) } }
/** Whether the library folds each franchise into one tile. */
var libraryGrouped: Boolean
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true)
@@ -62,5 +67,6 @@ class SettingsStore(context: Context) {
private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib"
private const val KEY_LIBRARY_SORT = "library_sort"
private const val KEY_LIBRARY_GROUPED = "library_grouped"
private const val KEY_METADATA_OFFER_DISMISSED = "metadata_offer_dismissed"
}
}
@@ -19,6 +19,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.ui.graphics.Brush
import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
import androidx.compose.material3.Surface
import androidx.compose.ui.platform.LocalContext
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.QuietButton
import java.time.LocalDate
import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.CheckCircle
@@ -105,6 +109,29 @@ private data class Resumable(
/** 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)
/** The offer of posters and synopses, and its two answers. */
internal class MetadataOffer(val onOpenSettings: () -> Unit, val onDismiss: () -> Unit)
@Composable
private fun MetadataOfferCard(offer: MetadataOffer) {
Surface(color = MaterialTheme.colorScheme.surfaceContainer, shape = MaterialTheme.shapes.medium) {
Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
Text("Add posters and synopses?", style = MaterialTheme.typography.titleSmall)
Text(
"Turning it on in Settings sends the titles in your library — not your files, and " +
"nothing about what you have watched — to AniList or TMDB.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, end = 8.dp),
)
Row(Modifier.align(Alignment.End)) {
QuietButton(onClick = offer.onDismiss) { Text("Not now") }
QuietButton(onClick = offer.onOpenSettings) { Text("Open settings") }
}
}
}
}
/** 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>)
@@ -123,8 +150,13 @@ internal fun LibraryScreen(
onError: (Throwable) -> Unit = {},
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
onAddShare: () -> Unit = {},
onOpenSettings: () -> Unit = {},
) {
val download = rememberDownload()
val context = LocalContext.current
// Posters and synopses, offered once there is a library to put them on
// and never turned on unasked: switching them on sends title names out.
var offerDismissed by remember { mutableStateOf(SettingsStore(context).metadataOfferDismissed) }
// What a long press opened a menu for: a title, or a Continue watching card.
var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
var menuResume by remember { mutableStateOf<Resumable?>(null) }
@@ -204,6 +236,14 @@ internal fun LibraryScreen(
onTitle,
onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = it },
metadataOffer = if (state.metadataSettings.enabled || offerDismissed) {
null
} else {
MetadataOffer(onOpenSettings) {
offerDismissed = true
SettingsStore(context).metadataOfferDismissed = true
}
},
)
}
}
@@ -398,6 +438,7 @@ private fun LibraryGrid(
onTitle: (TitleRecord) -> Unit,
onTitleMenu: (TitleRecord) -> Unit,
onResumeMenu: (Resumable) -> Unit,
metadataOffer: MetadataOffer? = null,
) {
// The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid.
@@ -424,6 +465,9 @@ private fun LibraryGrid(
// opening the app wants is almost always the thing they were part way
// through. Inside the grid rather than pinned over it, so the shelf
// scrolls away with the rest.
if (metadataOffer != null && state.query.isBlank()) {
item(key = "metadata-offer", span = { GridItemSpan(maxLineSpan) }) { MetadataOfferCard(metadataOffer) }
}
val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
featured?.let { title ->
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
@@ -231,6 +231,7 @@ fun ProtonStreamApp(
destination = Destination.Shares
addingShare = true
},
onOpenSettings = { destination = Destination.Settings },
)
}
val titlePane = @Composable { selected: TitleRecord ->
@@ -27,6 +27,8 @@ import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord
import uniffi.pstr_android.OfflineRecord
import uniffi.pstr_android.PaletteRecord
import uniffi.pstr_android.SeasonRecord
@@ -79,18 +81,23 @@ class ScreenshotTest {
@Test
fun `the library on a phone`() = capture("library-phone") {
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library offering posters while they are off`() = capture("library-phone-offer") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `the library on a tablet`() = capture("library-tablet") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
fun `the library in the light palette`() = captureLight("library-phone-light") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
@Test
@@ -116,7 +123,7 @@ class ScreenshotTest {
// reads it from, so the scale is set there.
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
}
}
@@ -293,6 +300,16 @@ class ScreenshotTest {
val FILM = title("akira", "Akira", listOf(SeasonRecord(null, "Film", listOf(episode(1u, 1u)))))
.copy(kind = TitleType.FILM, year = 1988u)
/** [LIBRARY] with posters on, as the shelf above its grid implies. */
val MATCHED get() = LIBRARY.copy(
metadataSettings = MetadataSettingsRecord(
enabled = true,
provider = MetadataProvider.ANI_LIST,
language = "en",
ready = true,
),
)
val LIBRARY = AppUiState(
loading = false,
titles = listOf(
Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

+2 -1
View File
@@ -203,7 +203,8 @@ struct RootView: View {
onAddShare: {
incomingLink = ""
destination = .shares
}
},
onOpenSettings: { select(.settings) }
)
if width >= twoPaneWidth {
HStack(spacing: 0) {
+7
View File
@@ -51,6 +51,12 @@ struct SettingsStore {
nonmutating set { defaults.set(String(describing: newValue), forKey: Key.librarySort) }
}
/// The library's offer of posters and synopses was turned down.
var metadataOfferDismissed: Bool {
get { bool(Key.metadataOfferDismissed, default: false) }
nonmutating set { defaults.set(newValue, forKey: Key.metadataOfferDismissed) }
}
/// Whether the library folds each franchise into one tile.
var libraryGrouped: Bool {
get { bool(Key.libraryGrouped, default: true) }
@@ -70,5 +76,6 @@ struct SettingsStore {
static let cacheBudgetGib = "cache_budget_gib"
static let librarySort = "library_sort"
static let libraryGrouped = "library_grouped"
static let metadataOfferDismissed = "metadata_offer_dismissed"
}
}
+33
View File
@@ -58,12 +58,16 @@ struct LibraryView: View {
let onResume: (TitleRecord, Int) -> Void
let onTitle: (TitleRecord) -> Void
var onAddShare: () -> Void = {}
var onOpenSettings: () -> Void = {}
// What a long press opened a menu for: a title, or a Continue watching card.
@State private var menuFor: TitleRecord?
@State private var menuResume: Resumable?
@State private var matching: TitleRecord?
@State private var downloading: DownloadRequest?
// Posters and synopses, offered once there is a library to put them on
// and never turned on unasked: switching them on sends title names out.
@State private var offerDismissed = SettingsStore().metadataOfferDismissed
/// The same, for a download asked about once the menu has gone.
@State private var pendingDownload: DownloadRequest?
/// "Change match" picked in the tile menu, waiting for that menu to finish
@@ -196,6 +200,12 @@ struct LibraryView: View {
}
}
}
if browsing, !model.metadataSettings.enabled, !offerDismissed {
MetadataOfferCard(onOpenSettings: onOpenSettings) {
offerDismissed = true
SettingsStore().metadataOfferDismissed = true
}
}
if let featured {
FeaturedBanner(
title: featured,
@@ -411,6 +421,29 @@ private struct LibrarySkeleton: View {
}
}
/// The offer of posters and synopses, and its two answers.
private struct MetadataOfferCard: View {
let onOpenSettings: () -> Void
let onDismiss: () -> Void
@Environment(\.scheme) private var scheme
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Add posters and synopses?").textStyle(.titleSmall).foregroundStyle(scheme.onSurface)
Text("Turning it on in Settings sends the titles in your library — not your files, and nothing about what you have watched — to AniList or TMDB.")
.textStyle(.bodySmall)
.foregroundStyle(scheme.onSurfaceVariant)
HStack {
Spacer()
Button("Not now", action: onDismiss).buttonStyle(.quiet)
Button("Open settings", action: onOpenSettings).buttonStyle(.quiet)
}
}
.padding(EdgeInsets(top: 12, leading: 16, bottom: 4, trailing: 8))
.background(scheme.surfaceContainer, in: RoundedRectangle(cornerRadius: 12))
}
}
/// The title the banner shows: the one watched last, or with nothing part
/// watched, one of the titles the provider has a backdrop for, turning over
/// once a day — the desktop's `featured`.