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) get() = preferences.getString(KEY_LIBRARY_SORT, null)
set(value) { preferences.edit { putString(KEY_LIBRARY_SORT, value) } } 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. */ /** Whether the library folds each franchise into one tile. */
var libraryGrouped: Boolean var libraryGrouped: Boolean
get() = preferences.getBoolean(KEY_LIBRARY_GROUPED, true) 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_CACHE_BUDGET_GIB = "cache_budget_gib"
private const val KEY_LIBRARY_SORT = "library_sort" private const val KEY_LIBRARY_SORT = "library_sort"
private const val KEY_LIBRARY_GROUPED = "library_grouped" 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 androidx.compose.ui.graphics.Brush
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton 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 java.time.LocalDate
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.filled.CheckCircle 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. */ /** 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) 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. */ /** 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>) private data class Shelf(val label: String, val titles: List<TitleRecord>)
@@ -123,8 +150,13 @@ internal fun LibraryScreen(
onError: (Throwable) -> Unit = {}, onError: (Throwable) -> Unit = {},
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> }, onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
onAddShare: () -> Unit = {}, onAddShare: () -> Unit = {},
onOpenSettings: () -> Unit = {},
) { ) {
val download = rememberDownload() 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. // What a long press opened a menu for: a title, or a Continue watching card.
var menuFor by remember { mutableStateOf<TitleRecord?>(null) } var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
var menuResume by remember { mutableStateOf<Resumable?>(null) } var menuResume by remember { mutableStateOf<Resumable?>(null) }
@@ -204,6 +236,14 @@ internal fun LibraryScreen(
onTitle, onTitle,
onTitleMenu = { menuFor = it }, onTitleMenu = { menuFor = it },
onResumeMenu = { menuResume = 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, onTitle: (TitleRecord) -> Unit,
onTitleMenu: (TitleRecord) -> Unit, onTitleMenu: (TitleRecord) -> Unit,
onResumeMenu: (Resumable) -> Unit, onResumeMenu: (Resumable) -> Unit,
metadataOffer: MetadataOffer? = null,
) { ) {
// The arrangement names titles by key; before it has arrived, the titles // The arrangement names titles by key; before it has arrived, the titles
// as they came are the grid. // 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 // 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 // through. Inside the grid rather than pinned over it, so the shelf
// scrolls away with the rest. // 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 val featured = if (state.query.isBlank()) featured(state.titles, resumable) else null
featured?.let { title -> featured?.let { title ->
item(key = "featured", span = { GridItemSpan(maxLineSpan) }) { item(key = "featured", span = { GridItemSpan(maxLineSpan) }) {
@@ -231,6 +231,7 @@ fun ProtonStreamApp(
destination = Destination.Shares destination = Destination.Shares
addingShare = true addingShare = true
}, },
onOpenSettings = { destination = Destination.Settings },
) )
} }
val titlePane = @Composable { selected: TitleRecord -> val titlePane = @Composable { selected: TitleRecord ->
@@ -27,6 +27,8 @@ import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
import uniffi.pstr_android.ArrangementRecord import uniffi.pstr_android.ArrangementRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.MetadataProvider
import uniffi.pstr_android.MetadataSettingsRecord
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
@@ -79,18 +81,23 @@ class ScreenshotTest {
@Test @Test
fun `the library on a phone`() = capture("library-phone") { 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()) LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
} }
@Test @Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet) @Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `the library on a tablet`() = capture("library-tablet") { fun `the library on a tablet`() = capture("library-tablet") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues()) LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
} }
@Test @Test
fun `the library in the light palette`() = captureLight("library-phone-light") { fun `the library in the light palette`() = captureLight("library-phone-light") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues()) LibraryScreen(MATCHED, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
} }
@Test @Test
@@ -116,7 +123,7 @@ class ScreenshotTest {
// reads it from, so the scale is set there. // reads it from, so the scale is set there.
val density = LocalDensity.current val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) { 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))))) 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)
/** [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( val LIBRARY = AppUiState(
loading = false, loading = false,
titles = listOf( titles = listOf(
Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

+2 -1
View File
@@ -203,7 +203,8 @@ struct RootView: View {
onAddShare: { onAddShare: {
incomingLink = "" incomingLink = ""
destination = .shares destination = .shares
} },
onOpenSettings: { select(.settings) }
) )
if width >= twoPaneWidth { if width >= twoPaneWidth {
HStack(spacing: 0) { HStack(spacing: 0) {
+7
View File
@@ -51,6 +51,12 @@ struct SettingsStore {
nonmutating set { defaults.set(String(describing: newValue), forKey: Key.librarySort) } 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. /// Whether the library folds each franchise into one tile.
var libraryGrouped: Bool { var libraryGrouped: Bool {
get { bool(Key.libraryGrouped, default: true) } get { bool(Key.libraryGrouped, default: true) }
@@ -70,5 +76,6 @@ struct SettingsStore {
static let cacheBudgetGib = "cache_budget_gib" static let cacheBudgetGib = "cache_budget_gib"
static let librarySort = "library_sort" static let librarySort = "library_sort"
static let libraryGrouped = "library_grouped" 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 onResume: (TitleRecord, Int) -> Void
let onTitle: (TitleRecord) -> Void let onTitle: (TitleRecord) -> Void
var onAddShare: () -> Void = {} var onAddShare: () -> Void = {}
var onOpenSettings: () -> Void = {}
// 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.
@State private var menuFor: TitleRecord? @State private var menuFor: TitleRecord?
@State private var menuResume: Resumable? @State private var menuResume: Resumable?
@State private var matching: TitleRecord? @State private var matching: TitleRecord?
@State private var downloading: DownloadRequest? @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. /// The same, for a download asked about once the menu has gone.
@State private var pendingDownload: DownloadRequest? @State private var pendingDownload: DownloadRequest?
/// "Change match" picked in the tile menu, waiting for that menu to finish /// "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 { if let featured {
FeaturedBanner( FeaturedBanner(
title: featured, 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 /// 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 /// watched, one of the titles the provider has a backdrop for, turning over
/// once a day — the desktop's `featured`. /// once a day — the desktop's `featured`.