diff --git a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt index 9794492..f951b41 100644 --- a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt +++ b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt @@ -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" } } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index e4870fe..c005440 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -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) @@ -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(null) } var menuResume by remember { mutableStateOf(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) }) { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index e19ea8b..3f30f3f 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -231,6 +231,7 @@ fun ProtonStreamApp( destination = Destination.Shares addingShare = true }, + onOpenSettings = { destination = Destination.Settings }, ) } val titlePane = @Composable { selected: TitleRecord -> diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt index d075425..e3c96a7 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -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( diff --git a/android/app/src/test/screenshots/library-phone-offer.png b/android/app/src/test/screenshots/library-phone-offer.png new file mode 100644 index 0000000..56c1bba Binary files /dev/null and b/android/app/src/test/screenshots/library-phone-offer.png differ diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift index ef0a167..f528500 100644 --- a/ios/ProtonStream/App/ProtonStreamApp.swift +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -203,7 +203,8 @@ struct RootView: View { onAddShare: { incomingLink = "" destination = .shares - } + }, + onOpenSettings: { select(.settings) } ) if width >= twoPaneWidth { HStack(spacing: 0) { diff --git a/ios/ProtonStream/App/SettingsStore.swift b/ios/ProtonStream/App/SettingsStore.swift index 8810147..5e49a53 100644 --- a/ios/ProtonStream/App/SettingsStore.swift +++ b/ios/ProtonStream/App/SettingsStore.swift @@ -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" } } diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 8ed8758..943ca57 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -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`.