From 911e22fd2f74ff687ba68d56a2ed3b5c549cee9a Mon Sep 17 00:00:00 2001 From: Ida Delphine Date: Fri, 23 Oct 2026 06:28:47 -0700 Subject: [PATCH 1/8] add history preference keys # Conflicts: # shared/src/commonTest/kotlin/net/newpipe/app/extensions/SettingsModule.kt --- .../kotlin/net/newpipe/app/extensions/SettingsModule.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/shared/src/commonTest/kotlin/net/newpipe/app/extensions/SettingsModule.kt b/shared/src/commonTest/kotlin/net/newpipe/app/extensions/SettingsModule.kt index e6e8fd87099..c04e7dfc44b 100644 --- a/shared/src/commonTest/kotlin/net/newpipe/app/extensions/SettingsModule.kt +++ b/shared/src/commonTest/kotlin/net/newpipe/app/extensions/SettingsModule.kt @@ -13,13 +13,6 @@ import org.koin.dsl.module /** * Builds a Koin module exposing [settings] as the only [Settings] instance. * - * Every test goes through this single `single` declaration on purpose. The Koin compiler - * plugin keeps the metadata of a `single` binding in the = - * names of the synthetic parameters it - * generates, and parameter names are not part of an IR signature. Declaring `single` in - * more than one place therefore produces colliding signatures that fail klib serialization on the - * native targets. - * * @param settings Settings to expose, empty by default */ fun settingsModule(settings: Settings = MapSettings()): Module = module { From 0d974756c9e284606e5bdcad94a19da227befcb0 Mon Sep 17 00:00:00 2001 From: Ida Delphine Date: Fri, 23 Oct 2026 06:55:45 -0700 Subject: [PATCH 2/8] add history settings test --- .../kotlin/net/newpipe/app/extensions/SettingsModule.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/shared/src/commonTest/kotlin/net/newpipe/app/extensions/SettingsModule.kt b/shared/src/commonTest/kotlin/net/newpipe/app/extensions/SettingsModule.kt index c04e7dfc44b..e6e8fd87099 100644 --- a/shared/src/commonTest/kotlin/net/newpipe/app/extensions/SettingsModule.kt +++ b/shared/src/commonTest/kotlin/net/newpipe/app/extensions/SettingsModule.kt @@ -13,6 +13,13 @@ import org.koin.dsl.module /** * Builds a Koin module exposing [settings] as the only [Settings] instance. * + * Every test goes through this single `single` declaration on purpose. The Koin compiler + * plugin keeps the metadata of a `single` binding in the = + * names of the synthetic parameters it + * generates, and parameter names are not part of an IR signature. Declaring `single` in + * more than one place therefore produces colliding signatures that fail klib serialization on the + * native targets. + * * @param settings Settings to expose, empty by default */ fun settingsModule(settings: Settings = MapSettings()): Module = module { From cc4b5e86b7f207ac811e08ca37ded964b88ce712 Mon Sep 17 00:00:00 2001 From: Ida Delphine Date: Mon, 26 Oct 2026 12:14:03 -0700 Subject: [PATCH 3/8] add string resources --- .../composeResources/values/content_array.xml | 858 ++++++++++++++++++ .../composeResources/values/strings.xml | 67 ++ 2 files changed, 925 insertions(+) create mode 100644 shared/src/commonMain/composeResources/values/content_array.xml diff --git a/shared/src/commonMain/composeResources/values/content_array.xml b/shared/src/commonMain/composeResources/values/content_array.xml new file mode 100644 index 00000000000..74ba983758d --- /dev/null +++ b/shared/src/commonMain/composeResources/values/content_array.xml @@ -0,0 +1,858 @@ + + + Basa Acèh + العربية + العربية (ليبيا) + Azərbaycanca + Asturianu + Беларуская + ⵜⴰⵎⴰⵣⵉⵖⵜ + български език + বাংলা + বাংলা (বাংলাদেশ) + বাংলা (भारत) + Brezhoneg + Босански + Català + کوردیی سۆرانی + Čeština + Dansk + Deutsch + Ελληνικά + English + Esperanto + Español + Eesti keel + Euskara + فارسی + Suomen kieli + Wikang Filipino + Français + Galego + ગુજરાતી + עברית + हिन्दी + Hrvatski + magyar + Հայերեն + Interlingua + Bahasa Indonesia + Íslenska + Italiano + 日本語 + ꦧꦱꦗꦮ + ქართული + Taqbaylit + Kurmancî + ಕನ್ನಡ + 한국어 + کوردی + Latina + Lietuvių kalba + latviski + македонски јазик + മലയാളം + Монгол хэл + मराठी + Bahasa Melayu + Norsk bokmål + Norsk Nynorsk + नेपाली + Nederlands (NL) + Nederlands (BE) + ߒߞߏ + Occitan + ଓଡ଼ିଆ + ਪੰਜਾਬੀ + ਪੰਜਾਬੀ (PK) + Polski + Português + Português (BR) + Português (PT) + Română + Pусский + うちなーぐち + ᱥᱟᱱᱛᱟᱲᱤ + sardu + Slovenčina + Slovenščina + Soomaali + Shqip + Српски + Svenska + தமிழ் + తెలుగు + ไทย + ትግርኛ + Wikang Tagalog + Toki Pona + Türkçe + Татар теле + Tamaziɣt + українська мова + اردو + O‘zbek + Tiếng Việt + 简体中文 + 繁體中文(廣東話) + 正體中文 + + + ace + ar + ar-ly + az + ast + be + ber + bg + bn + bn-bd + bn-in + br + bs + ca + ckb + cs + da + de + el + en + eo + es + et + eu + fa + fi + fil + fr + gl + gu + he + hi + hr + hu + hy + ia + in + is + it + ja + jv + ka + kab + kmr + kn + ko + ku + la + lt + lv + mk + ml + mn + mr + ms + nb-no + nn + ne + nl + nl-be + nqo + oc + or + pa + pa-pk + pl + pt + pt-br + pt-pt + ro + ru + ryu + sat + sc + sk + sl + so + sq + sr + sv + ta + te + th + ti + tl + tok + tr + tt + tzm + uk + ur + uz + vi + zh-cn + zh-hk + zh-tw + + + Afrikaans + Azərbaycanca + Bahasa Indonesia + Bahasa Malaysia + Català + Čeština + Dansk + Deutsch + Eesti + English (UK) + English (US) + Español (España) + Español (Latinoamérica) + Euskara + Filipino + Français + Français (Canada) + Galego + Hrvatski + IsiZulu + Íslenska + Italiano + Kiswahili + Lietuvių + Magyar + Nederlands + ߒߞߏ + Norsk + Nynorsk + O‘zbek + ଓଡ଼ିଆ + Polski + Português + Português (Brasil) + Română + Shqip + Slovenčina + Slovenščina + Suomi + Svenska + Tibetan བོད་སྐད། + Tiếng Việt + Türkçe + Български + Кыргызча + Македонски + Монгол + Русский + Српски + Українська + Ελληνικά + Հայերեն + עברית + اردو + العربية + فارسی + नेपाली + मराठी + हिन्दी + বাংলা + ਪੰਜਾਬੀ + ગુજરાતી + தமிழ் + తెలుగు + ಕನ್ನಡ + മലയാളം + සිංහල + ภาษาไทย + ລາວ + ဗမာ + ქართული + አማርኛ + ខ្មែរ + 中文 (简体) + 中文 (繁體) + 中文 (香港) + 日本語 + うちなーぐち + 한국어 + + + af + az + id + ms + ca + cs + da + de + et + en-GB + en + es + es-419 + eu + fil + fr + fr-CA + gl + hr + zu + is + it + sw + lt + hu + nl + nqo + no + nn + uz + or + pl + pt-PT + pt + ro + sq + sk + sl + fi + sv + bo + vi + tr + bg + ky + mk + mn + ru + sr + uk + el + hy + iw + ur + ar + fa + ne + mr + hi + bn + pa + gu + ta + te + kn + ml + si + th + lo + my + ka + am + km + zh-CN + zh-TW + zh-HK + ja + ryu + ko + + + Afghanistan + Aland Islands + Albania + Algeria + American Samoa + Andorra + Angola + Anguilla + Antartica + Antigua and Barbuda + Argentina + Armenia + Aruba + Australia + Austria + Azerbaijan + Bahamas + Bahrain + Bangladesh + Barbados + Belarus + Belgium + Belize + Benin + Bermuda + Bhutan + Bolivia + Bosnia and Herzegovina + Botswana + Bouvet Island + Brazil + British Virgin Islands + British Indian Ocean Territory + Brunei Darussalam + Bulgaria + Burkina Faso + Burundi + Cambodia + Cameroon + Canada + Cape Verde + Cayman Islands + Central African Republic + Chad + Chile + China + Hong Kong (China) + Macao (China) + Christmas Island + Cocos (Keeling) Islands + Colombia + Comoros + Brazzaville (Congo) + Kinshasa (Congo) + Cook Islands + Costa Rica + Côte d'Ivoire + Croatia + Cuba + Cyprus + Czech Republic + Denmark + Djibouti + Dominica + Dominican Republic + Ecuador + Egypt + El Salvador + Equatorial Guinea + Eritrea + Estonia + Ethiopia + Falkland Islands (Malvinas) + Faroe Islands + Fiji + Finland + France + French Guiana + French Polynesia + French Southern Territories + Gabon + Gambia + Georgia + Germany + Ghana + Gibraltar + Greece + Greenland + Grenada + Guadeloupe + Guam + Guatemala + Guernsey + Guinea + Guinea-Bissau + Guyana + Haiti + Heard and McDonald Islands + Holy See (Vatican City State) + Honduras + Hungary + Iceland + India + Indonesia + Iran + Iraq + Ireland + Isle of Man + Israel + Italy + Jamaica + Japan + Jersey + Jordan + Kazakhstan + Kenya + Kiribati + North Korea + South Korea + Kuwait + Kyrgyzstan + Lao + Latvia + Lebanon + Lesotho + Liberia + Libya + Liechtenstein + Lithuania + Luxembourg + Macedonia + Madagascar + Malawi + Malaysia + Maldives + Mali + Malta + Marshall Islands + Martinique + Mauritania + Mauritius + Mayotte + Mexico + Micronesia + Moldova + Monaco + Mongolia + Montenegro + Montserrat + Morocco + Mozambique + Myanmar + Namibia + Nauru + Nepal + Netherlands + Netherlands Antilles + New Caledonia + New Zealand + Nicaragua + Niger + Nigeria + Niue + Norfolk Island + Northern Mariana Islands + Norway + Oman + Pakistan + Palau + Palestine + Panama + Papua New Guinea + Paraguay + Peru + Philippines + Pitcairn + Poland + Portugal + Puerto Rico + Qatar + Réunion + Romania + Russian Federation + Rwanda + Saint Barthélemy + Saint Helena + Saint Kitts and Nevis + Saint Lucia + Saint Martin + Saint Pierre and Miquelon + Saint Vincent and Grenadines + Samoa + San Marino + Sao Tome and Principe + Saudi Arabia + Senegal + Serbia + Seychelles + Sierra Leone + Singapore + Slovakia + Slovenia + Solomon Islands + Somalia + South Africa + South Georgia and South Sandwich Islands + South Sudan + Spain + Sri Lanka + Sudan + Suriname + Svalbard and Jan Mayen Islands + Swaziland + Sweden + Switzerland + Syrian Arab Republic (Syria) + Taiwan + Tajikistan + Tanzania + Thailand + Timor Leste + Togo + Tokelau + Tonga + Trinidad and Tobago + Tunisia + Turkey + Turkmenistan + Turks and Caicos Islands + Tuvalu + Uganda + Ukraine + United Arab Emirates + United Kingdom + United States + Minor Outlying Islands + Uruguay + Uzbekistan + Vanuatu + Venezuela (Bolivarian Republic) + Vietnam + Virgin Islands + Wallis and Futuna Islands + Western Sahara + Yemen + Zambia + Zimbabwe + + + AF + AX + AL + DZ + AS + AD + AO + AI + AQ + AG + AR + AM + AW + AU + AT + AZ + BS + BH + BD + BB + BY + BE + BZ + BJ + BM + BT + BO + BA + BW + BV + BR + VG + IO + BN + BG + BF + BI + KH + CM + CA + CV + KY + CF + TD + CL + CN + HK + MO + CX + CC + CO + KM + CG + CD + CK + CR + CI + HR + CU + CY + CZ + DK + DJ + DM + DO + EC + EG + SV + GQ + ER + EE + ET + FK + FO + FJ + FI + FR + GF + PF + TF + GA + GM + GE + DE + GH + GI + GR + GL + GD + GP + GU + GT + GG + GN + GW + GY + HT + HM + VA + HN + HU + IS + IN + ID + IR + IQ + IE + IM + IL + IT + JM + JP + JE + JO + KZ + KE + KI + KP + KR + KW + KG + LA + LV + LB + LS + LR + LY + LI + LT + LU + MK + MG + MW + MY + MV + ML + MT + MH + MQ + MR + MU + YT + MX + FM + MD + MC + MN + ME + MS + MA + MZ + MM + NA + NR + NP + NL + AN + NC + NZ + NI + NE + NG + NU + NF + MP + NO + OM + PK + PW + PS + PA + PG + PY + PE + PH + PN + PL + PT + PR + QA + RE + RO + RU + RW + BL + SH + KN + LC + MF + PM + VC + WS + SM + ST + SA + SN + RS + SC + SL + SG + SK + SI + SB + SO + ZA + GS + SS + ES + LK + SD + SR + SJ + SZ + SE + CH + SY + TW + TJ + TZ + TH + TL + TG + TK + TO + TT + TN + TR + TM + TC + TV + UG + UA + AE + GB + US + UM + UY + UZ + VU + VE + VN + VI + WF + EH + YE + ZM + ZW + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index a861a101ccb..8272b792543 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -249,5 +249,72 @@ Clear cookies that NewPipe stores when you solve a reCAPTCHA reCAPTCHA cookies have been cleared + + App language + System default + Default content language + Default content country + Content of main page + What tabs are shown on the main page + Channel tabs + What tabs are shown on the channel pages + PeerTube instances + Select your favorite PeerTube instances + Show age restricted content + Show content possibly unsuitable for children because it has an age limit (like 18+) + Turn on YouTube's "Restricted Mode" + YouTube provides a "Restricted Mode" which hides potentially mature content + Search suggestions + Choose the suggestions to show when searching + Local search suggestions + Remote search suggestions + Image quality + Choose the quality of images and whether to load images at all, to reduce data and memory usage. Changes clear both in-memory and on-disk image cache — %1$s + Do not load images + Low quality + Medium quality + High quality + Image cache wiped + Show comments + Turn off to hide comments + Show 'Next' and 'Similar' videos + Show description + Turn off to hide video description and additional information + Show meta info + Turn off to hide meta info boxes with additional information about the stream creator, stream content or a search request + Videos + Tracks + Shorts + Live + Channels + Playlists + Albums + Likes + Podcasts + Courses + About + + + Feed + Feed update threshold + Time after last update before a subscription is considered outdated — %1$s + Always update + Fetch from dedicated feed when available + Available in some services, it is usually much faster but may return a limited amount of items and often incomplete information (e.g. no duration, item type, no live status) + Fetch channel tabs + Tabs to fetch when updating the feed. This option has no effect if a channel is updated using fast mode. + + %1$d minute + %1$d minutes + + + %1$d hour + %1$d hours + + + %1$d day + %1$d days + + From db7ce04eb06f20550e448e52ecb08de6fdbc4397 Mon Sep 17 00:00:00 2001 From: Ida Delphine Date: Mon, 26 Oct 2026 12:46:48 -0700 Subject: [PATCH 4/8] add platform json string set store --- .../app/platform/AndroidStringSetStore.kt | 24 +++++++++ .../net/newpipe/app/platform/ContentHost.kt | 2 + .../newpipe/app/platform/ContentActions.kt | 2 + .../app/platform/JsonStringSetStore.kt | 23 +++++++++ .../newpipe/app/platform/StringSetStore.kt | 16 ++++++ .../app/preferences/ContentPreferences.kt | 49 +++++++++++++++++++ .../app/screen/settings/model/ChannelTab.kt | 46 +++++++++++++++++ .../newpipe/app/platform/IOSStringSetStore.kt | 13 +++++ .../newpipe/app/platform/JVMStringSetStore.kt | 13 +++++ 9 files changed, 188 insertions(+) create mode 100644 shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidStringSetStore.kt create mode 100644 shared/src/androidMain/kotlin/net/newpipe/app/platform/ContentHost.kt create mode 100644 shared/src/commonMain/kotlin/net/newpipe/app/platform/ContentActions.kt create mode 100644 shared/src/commonMain/kotlin/net/newpipe/app/platform/JsonStringSetStore.kt create mode 100644 shared/src/commonMain/kotlin/net/newpipe/app/platform/StringSetStore.kt create mode 100644 shared/src/commonMain/kotlin/net/newpipe/app/preferences/ContentPreferences.kt create mode 100644 shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/model/ChannelTab.kt create mode 100644 shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSStringSetStore.kt create mode 100644 shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMStringSetStore.kt diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidStringSetStore.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidStringSetStore.kt new file mode 100644 index 00000000000..0a02d8821bc --- /dev/null +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidStringSetStore.kt @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.platform + +import android.content.Context +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import org.koin.core.annotation.Singleton + +@Singleton(binds = [StringSetStore::class]) +class AndroidStringSetStore(context: Context) : StringSetStore { + + private val preferences = PreferenceManager.getDefaultSharedPreferences(context) + + override fun get(key: String, defaultValue: Set): Set = preferences + .getStringSet(key, null)?.toSet() ?: defaultValue + + override fun put(key: String, value: Set) = preferences.edit { + putStringSet(key, value) + } +} diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/ContentHost.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/ContentHost.kt new file mode 100644 index 00000000000..38452c7f08c --- /dev/null +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/ContentHost.kt @@ -0,0 +1,2 @@ +package net.newpipe.app.platform + diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/platform/ContentActions.kt b/shared/src/commonMain/kotlin/net/newpipe/app/platform/ContentActions.kt new file mode 100644 index 00000000000..38452c7f08c --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/platform/ContentActions.kt @@ -0,0 +1,2 @@ +package net.newpipe.app.platform + diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/platform/JsonStringSetStore.kt b/shared/src/commonMain/kotlin/net/newpipe/app/platform/JsonStringSetStore.kt new file mode 100644 index 00000000000..7d4b01fe96a --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/platform/JsonStringSetStore.kt @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.platform + +import com.russhwolf.settings.Settings +import kotlinx.serialization.json.Json + +/** + * Keeps each set as a JSON array string + */ +internal class JsonStringSetStore( + private val settings: Settings, + private val json: Json +) : StringSetStore { + + override fun get(key: String, defaultValue: Set): Set = settings.getStringOrNull(key)?.let { stored -> json.decodeFromString>(stored) } + ?: defaultValue + + override fun put(key: String, value: Set) = settings.putString(key, json.encodeToString(value)) +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/platform/StringSetStore.kt b/shared/src/commonMain/kotlin/net/newpipe/app/platform/StringSetStore.kt new file mode 100644 index 00000000000..3fa4d19cb2a --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/platform/StringSetStore.kt @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.platform + +/** + * Stores sets of strings + */ +interface StringSetStore { + + fun get(key: String, defaultValue: Set): Set + + fun put(key: String, value: Set) +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/preferences/ContentPreferences.kt b/shared/src/commonMain/kotlin/net/newpipe/app/preferences/ContentPreferences.kt new file mode 100644 index 00000000000..d4b836825d6 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/preferences/ContentPreferences.kt @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.preferences + +/** + * Preference keys, values, and defaults for the Content settings screen + */ +object ContentPreferences { + const val KEY_APP_LANGUAGE = "app_language_key" + const val KEY_CONTENT_LANGUAGE = "content_language" + const val KEY_CONTENT_COUNTRY = "content_country" + const val KEY_CHANNEL_TABS = "channel_tabs" + const val KEY_SHOW_AGE_RESTRICTED_CONTENT = "show_age_restricted_content" + const val KEY_YOUTUBE_RESTRICTED_MODE = "youtube_restricted_mode_enabled" + const val KEY_SEARCH_SUGGESTIONS = "show_search_suggestions" + const val KEY_IMAGE_QUALITY = "image_quality_key" + const val KEY_SHOW_COMMENTS = "show_comments" + const val KEY_SHOW_NEXT_VIDEO = "show_next_video" + const val KEY_SHOW_DESCRIPTION = "show_description" + const val KEY_SHOW_META_INFO = "show_meta_info" + const val KEY_FEED_UPDATE_THRESHOLD = "feed_update_threshold_key" + const val KEY_FEED_USE_DEDICATED_FETCH_METHOD = "feed_use_dedicated_fetch_method" + const val KEY_FEED_FETCH_CHANNEL_TABS = "feed_fetch_channel_tabs" + + const val SEARCH_SUGGESTIONS_LOCAL = "show_local_search_suggestions" + const val SEARCH_SUGGESTIONS_REMOTE = "show_remote_search_suggestions" + + const val IMAGE_QUALITY_NONE = "image_quality_none" + const val IMAGE_QUALITY_LOW = "image_quality_low" + const val IMAGE_QUALITY_MEDIUM = "image_quality_medium" + const val IMAGE_QUALITY_HIGH = "image_quality_high" + + val FEED_UPDATE_THRESHOLDS = listOf("0", "300", "900", "3600", "21600", "43200", "86400") + + const val DEFAULT_LOCALIZATION = "system" + const val DEFAULT_SHOW_AGE_RESTRICTED_CONTENT = false + const val DEFAULT_YOUTUBE_RESTRICTED_MODE = false + val DEFAULT_SEARCH_SUGGESTIONS = setOf(SEARCH_SUGGESTIONS_LOCAL, SEARCH_SUGGESTIONS_REMOTE) + const val DEFAULT_IMAGE_QUALITY = IMAGE_QUALITY_MEDIUM + const val DEFAULT_SHOW_COMMENTS = true + const val DEFAULT_SHOW_NEXT_VIDEO = true + const val DEFAULT_SHOW_DESCRIPTION = true + const val DEFAULT_SHOW_META_INFO = true + const val DEFAULT_FEED_UPDATE_THRESHOLD = "300" + const val DEFAULT_FEED_USE_DEDICATED_FETCH_METHOD = false +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/model/ChannelTab.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/model/ChannelTab.kt new file mode 100644 index 00000000000..ff15c4fc6b2 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/model/ChannelTab.kt @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.screen.settings.model + +import newpipe.shared.generated.resources.Res +import newpipe.shared.generated.resources.channel_tab_about +import newpipe.shared.generated.resources.channel_tab_albums +import newpipe.shared.generated.resources.channel_tab_channels +import newpipe.shared.generated.resources.channel_tab_courses +import newpipe.shared.generated.resources.channel_tab_likes +import newpipe.shared.generated.resources.channel_tab_livestreams +import newpipe.shared.generated.resources.channel_tab_playlists +import newpipe.shared.generated.resources.channel_tab_podcasts +import newpipe.shared.generated.resources.channel_tab_shorts +import newpipe.shared.generated.resources.channel_tab_tracks +import newpipe.shared.generated.resources.channel_tab_videos +import org.jetbrains.compose.resources.StringResource + +/** + * Tabs of a channel page, as listed in the Content settings. + */ +enum class ChannelTab(val title: StringResource, val showKey: String, val fetchKey: String?) { + VIDEOS(Res.string.channel_tab_videos, "show_channel_tabs_videos", "fetch_channel_tabs_videos"), + TRACKS(Res.string.channel_tab_tracks, "show_channel_tabs_tracks", "fetch_channel_tabs_tracks"), + SHORTS(Res.string.channel_tab_shorts, "show_channel_tabs_shorts", "fetch_channel_tabs_shorts"), + LIVESTREAMS( + Res.string.channel_tab_livestreams, + "show_channel_tabs_livestreams", + "fetch_channel_tabs_livestreams" + ), + CHANNELS(Res.string.channel_tab_channels, "show_channel_tabs_channels", null), + PLAYLISTS(Res.string.channel_tab_playlists, "show_channel_tabs_playlists", null), + ALBUMS(Res.string.channel_tab_albums, "show_channel_tabs_albums", null), + LIKES(Res.string.channel_tab_likes, "show_channel_tabs_likes", "fetch_channel_tabs_likes"), + PODCASTS(Res.string.channel_tab_podcasts, "show_channel_tabs_podcasts", "fetch_channel_tabs_podcasts"), + COURSES(Res.string.channel_tab_courses, "show_channel_tabs_courses", "fetch_channel_tabs_courses"), + ABOUT(Res.string.channel_tab_about, "show_channel_tabs_about", null); + + companion object { + val SHOW_KEYS: Set = entries.mapTo(linkedSetOf()) { tab -> tab.showKey } + val FETCH_KEYS: Set = entries.mapNotNullTo(linkedSetOf()) { tab -> tab.fetchKey } + } +} diff --git a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSStringSetStore.kt b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSStringSetStore.kt new file mode 100644 index 00000000000..1300ecb054f --- /dev/null +++ b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSStringSetStore.kt @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.platform + +import com.russhwolf.settings.Settings +import kotlinx.serialization.json.Json +import org.koin.core.annotation.Singleton + +@Singleton(binds = [StringSetStore::class]) +class IOSStringSetStore(settings: Settings, json: Json) : StringSetStore by JsonStringSetStore(settings, json) diff --git a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMStringSetStore.kt b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMStringSetStore.kt new file mode 100644 index 00000000000..0e70b80a3f1 --- /dev/null +++ b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMStringSetStore.kt @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.platform + +import com.russhwolf.settings.Settings +import kotlinx.serialization.json.Json +import org.koin.core.annotation.Singleton + +@Singleton(binds = [StringSetStore::class]) +class JVMStringSetStore(settings: Settings, json: Json) : StringSetStore by JsonStringSetStore(settings, json) From 0374a02b12083f570272829d753c08ce68749e43 Mon Sep 17 00:00:00 2001 From: Ida Delphine Date: Mon, 26 Oct 2026 12:53:45 -0700 Subject: [PATCH 5/8] add platform content actions --- .../newpipe/settings/ComposeContentHost.kt | 36 ++++++++ .../app/platform/AndroidContentActions.kt | 82 +++++++++++++++++++ .../net/newpipe/app/platform/ContentHost.kt | 16 ++++ .../newpipe/app/platform/ContentActions.kt | 41 ++++++++++ .../newpipe/app/platform/IOSContentActions.kt | 11 +++ .../newpipe/app/platform/JVMContentActions.kt | 11 +++ 6 files changed, 197 insertions(+) create mode 100644 app/src/main/java/org/schabi/newpipe/settings/ComposeContentHost.kt create mode 100644 shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidContentActions.kt create mode 100644 shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSContentActions.kt create mode 100644 shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMContentActions.kt diff --git a/app/src/main/java/org/schabi/newpipe/settings/ComposeContentHost.kt b/app/src/main/java/org/schabi/newpipe/settings/ComposeContentHost.kt new file mode 100644 index 00000000000..8462c43d048 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/ComposeContentHost.kt @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.settings + +import android.content.Context +import coil3.SingletonImageLoader +import net.newpipe.app.platform.ContentHost +import org.schabi.newpipe.DownloaderImpl +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.player.helper.PlayerHelper +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.image.ImageStrategy +import org.schabi.newpipe.util.image.PreferredImageQuality + +class ComposeContentHost(private val context: Context) : ContentHost { + + override fun applyImageQuality(quality: String) { + ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(context, quality)) + val loader = SingletonImageLoader.get(context) + loader.memoryCache?.clear() + loader.diskCache?.clear() + } + + override fun applyYoutubeRestrictedMode(enabled: Boolean) = DownloaderImpl.getInstance().updateYoutubeRestrictedModeCookies(enabled) + + override fun applyContentLocalization() { + NewPipe.setupLocalization( + Localization.getPreferredLocalization(context), + Localization.getPreferredContentCountry(context) + ) + PlayerHelper.resetFormat() + } +} diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidContentActions.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidContentActions.kt new file mode 100644 index 00000000000..b1e8305b3bb --- /dev/null +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidContentActions.kt @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.platform + +import android.app.LocaleManager +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings as AndroidSettings +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import co.touchlab.kermit.Logger +import net.newpipe.app.preferences.ContentPreferences +import org.koin.core.annotation.Singleton + +@Singleton(binds = [ContentActions::class]) +class AndroidContentActions(private val context: Context) : ContentActions { + + private val host: ContentHost? = loadHost(context) + + // Android 13 moved per-app languages into the system settings + override val usesSystemAppLanguageSettings: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + override fun systemAppLanguageName(): String? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return null + } + val locales = context.getSystemService(LocaleManager::class.java).applicationLocales + return if (locales.isEmpty) null else locales[0].displayName + } + + override fun openSystemAppLanguageSettings() { + try { + context.startActivity( + Intent( + AndroidSettings.ACTION_APP_LOCALE_SETTINGS, + Uri.fromParts(SCHEME_PACKAGE, context.packageName, null) + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } catch (exception: ActivityNotFoundException) { + Logger.e(messageString = "No activity found for app language settings", throwable = exception) + } + } + + override fun applyAppLanguage(languageCode: String) = AppCompatDelegate.setApplicationLocales( + when (languageCode) { + ContentPreferences.DEFAULT_LOCALIZATION -> LocaleListCompat.getEmptyLocaleList() + else -> LocaleListCompat.forLanguageTags(languageCode) + } + ) + + override fun applyImageQuality(quality: String) { + host?.applyImageQuality(quality) + } + + override fun applyYoutubeRestrictedMode(enabled: Boolean) { + host?.applyYoutubeRestrictedMode(enabled) + } + + override fun applyContentLocalization() { + host?.applyContentLocalization() + } + + private fun loadHost(context: Context): ContentHost? = runCatching { + Class.forName(HOST_CLASS) + .getDeclaredConstructor(Context::class.java) + .newInstance(context) as ContentHost + }.onFailure { error -> + Logger.w(messageString = "No host content actions found, skipping host-side changes", throwable = error) + }.getOrNull() + + private companion object { + const val HOST_CLASS = "org.schabi.newpipe.settings.ComposeContentHost" + const val SCHEME_PACKAGE = "package" + } +} diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/ContentHost.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/ContentHost.kt index 38452c7f08c..7c22e608551 100644 --- a/shared/src/androidMain/kotlin/net/newpipe/app/platform/ContentHost.kt +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/ContentHost.kt @@ -1,2 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package net.newpipe.app.platform +/** + * Hooks into the image, network and extractor helpers that still live in the current Android app + */ +interface ContentHost { + + fun applyImageQuality(quality: String) + + fun applyYoutubeRestrictedMode(enabled: Boolean) + + fun applyContentLocalization() +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/platform/ContentActions.kt b/shared/src/commonMain/kotlin/net/newpipe/app/platform/ContentActions.kt index 38452c7f08c..c8cd3bc6fcc 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/platform/ContentActions.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/platform/ContentActions.kt @@ -1,2 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package net.newpipe.app.platform +import co.touchlab.kermit.Logger + +interface ContentActions { + val usesSystemAppLanguageSettings: Boolean + + fun systemAppLanguageName(): String? + + fun openSystemAppLanguageSettings() + + fun applyAppLanguage(languageCode: String) + + fun applyImageQuality(quality: String) + + fun applyYoutubeRestrictedMode(enabled: Boolean) + + fun applyContentLocalization() +} + +internal object NoOpContentActions : ContentActions { + + override val usesSystemAppLanguageSettings = false + + override fun systemAppLanguageName(): String? = null + + override fun openSystemAppLanguageSettings() = logUnsupported("openSystemAppLanguageSettings") + + override fun applyAppLanguage(languageCode: String) = logUnsupported("applyAppLanguage") + + override fun applyImageQuality(quality: String) = logUnsupported("applyImageQuality") + + override fun applyYoutubeRestrictedMode(enabled: Boolean) = logUnsupported("applyYoutubeRestrictedMode") + + override fun applyContentLocalization() = logUnsupported("applyContentLocalization") + + private fun logUnsupported(action: String) = Logger.w(messageString = "Content action '$action' is unsupported on this platform") +} diff --git a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSContentActions.kt b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSContentActions.kt new file mode 100644 index 00000000000..1925c409530 --- /dev/null +++ b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSContentActions.kt @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.platform + +import org.koin.core.annotation.Singleton + +@Singleton(binds = [ContentActions::class]) +class IOSContentActions : ContentActions by NoOpContentActions diff --git a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMContentActions.kt b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMContentActions.kt new file mode 100644 index 00000000000..f677c4f01d3 --- /dev/null +++ b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMContentActions.kt @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.platform + +import org.koin.core.annotation.Singleton + +@Singleton(binds = [ContentActions::class]) +class JVMContentActions : ContentActions by NoOpContentActions From 99352f04f263e35b61149eb5fcd181bc7465ab0b Mon Sep 17 00:00:00 2001 From: Ida Delphine Date: Mon, 26 Oct 2026 13:07:12 -0700 Subject: [PATCH 6/8] content settings screen and viewmodel --- .../newpipe/app/composable/ListPreference.kt | 13 +- .../composable/MultiSelectListPreference.kt | 131 +++++ .../screen/settings/ContentSettingsScreen.kt | 468 ++++++++++++++++++ .../settings/ContentSettingsViewModel.kt | 175 +++++++ 4 files changed, 784 insertions(+), 3 deletions(-) create mode 100644 shared/src/commonMain/kotlin/net/newpipe/app/composable/MultiSelectListPreference.kt create mode 100644 shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreen.kt create mode 100644 shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/ContentSettingsViewModel.kt diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/composable/ListPreference.kt b/shared/src/commonMain/kotlin/net/newpipe/app/composable/ListPreference.kt index 8580891a7ff..4f61e989fb6 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/composable/ListPreference.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/composable/ListPreference.kt @@ -6,10 +6,12 @@ package net.newpipe.app.composable import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.AlertDialog @@ -78,8 +80,13 @@ fun ListPreference( onDismissRequest = { showDialog = false }, title = { Text(text = title) }, text = { - Column(modifier = Modifier.selectableGroup()) { - entries.forEach { entry -> + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = entries + .indexOfFirst { it.value == selectedValue } + .coerceAtLeast(0) + ) + LazyColumn(state = listState, modifier = Modifier.selectableGroup()) { + items(items = entries, key = { entry -> entry.value }) { entry -> Row( modifier = Modifier .fillMaxWidth() diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/composable/MultiSelectListPreference.kt b/shared/src/commonMain/kotlin/net/newpipe/app/composable/MultiSelectListPreference.kt new file mode 100644 index 00000000000..7793b2525a2 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/composable/MultiSelectListPreference.kt @@ -0,0 +1,131 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* SPDX-License-Identifier: GPL-3.0-or-later +*/ + +package net.newpipe.app.composable + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import net.newpipe.app.preview.ThemePreviewProvider +import newpipe.shared.generated.resources.Res +import newpipe.shared.generated.resources.cancel +import newpipe.shared.generated.resources.ok +import org.jetbrains.compose.resources.stringResource + +/** + * A preference row that opens a multiple-choice dialog, mirroring AndroidX MultiSelectListPreference + * + * @param title Title text of the preference and of the dialog + * @param entries Selectable options + * @param selectedValues Currently stored values + * @param onValuesSelected Called with the chosen values once the dialog is confirmed + * @param modifier Modifier applied to the row + * @param summary Optional secondary line under the title + * @param enabled Whether the row can be opened + */ +@Composable +fun MultiSelectListPreference( + title: String, + entries: List, + selectedValues: Set, + onValuesSelected: (Set) -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, + enabled: Boolean = true +) { + var showDialog by remember { mutableStateOf(false) } + + PreferenceRow( + title = title, + modifier = modifier, + summary = summary, + enabled = enabled, + onClick = { showDialog = true } + ) + + if (showDialog) { + var pendingValues by remember { mutableStateOf(selectedValues) } + + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text(text = title) }, + text = { + LazyColumn { + items(items = entries, key = { entry -> entry.value }) { entry -> + val checked = entry.value in pendingValues + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = checked, + role = Role.Checkbox, + onValueChange = { isChecked -> + pendingValues = when { + isChecked -> pendingValues + entry.value + else -> pendingValues - entry.value + } + } + ) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = checked, onCheckedChange = null) + Text(text = entry.title, modifier = Modifier.padding(start = 16.dp)) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + onValuesSelected(pendingValues) + showDialog = false + } + ) { + Text(text = stringResource(Res.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { showDialog = false }) { + Text(text = stringResource(Res.string.cancel)) + } + } + ) + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@PreviewLightDark +@Composable +private fun MultiSelectListPreferencePreview() { + MultiSelectListPreference( + title = "Search suggestions", + summary = "Choose the suggestions to show when searching", + entries = listOf( + ListPreferenceEntry("local", "Local search suggestions"), + ListPreferenceEntry("remote", "Remote search suggestions") + ), + selectedValues = setOf("local"), + onValuesSelected = {} + ) +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreen.kt new file mode 100644 index 00000000000..7b3b331af5d --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreen.kt @@ -0,0 +1,468 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.screen.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import net.newpipe.app.composable.ListPreference +import net.newpipe.app.composable.ListPreferenceEntry +import net.newpipe.app.composable.MultiSelectListPreference +import net.newpipe.app.composable.PreferenceCategoryTitle +import net.newpipe.app.composable.PreferenceRow +import net.newpipe.app.composable.SwitchPreference +import net.newpipe.app.composable.TopAppBar +import net.newpipe.app.navigation.Navigator +import net.newpipe.app.platform.ContentActions +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_FEED_UPDATE_THRESHOLD +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_FEED_USE_DEDICATED_FETCH_METHOD +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_IMAGE_QUALITY +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_LOCALIZATION +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SEARCH_SUGGESTIONS +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SHOW_AGE_RESTRICTED_CONTENT +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SHOW_COMMENTS +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SHOW_DESCRIPTION +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SHOW_META_INFO +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SHOW_NEXT_VIDEO +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_YOUTUBE_RESTRICTED_MODE +import net.newpipe.app.preferences.ContentPreferences.FEED_UPDATE_THRESHOLDS +import net.newpipe.app.preferences.ContentPreferences.IMAGE_QUALITY_HIGH +import net.newpipe.app.preferences.ContentPreferences.IMAGE_QUALITY_LOW +import net.newpipe.app.preferences.ContentPreferences.IMAGE_QUALITY_MEDIUM +import net.newpipe.app.preferences.ContentPreferences.IMAGE_QUALITY_NONE +import net.newpipe.app.preferences.ContentPreferences.SEARCH_SUGGESTIONS_LOCAL +import net.newpipe.app.preferences.ContentPreferences.SEARCH_SUGGESTIONS_REMOTE +import net.newpipe.app.preview.ThemePreviewProvider +import net.newpipe.app.screen.settings.model.ChannelTab +import net.newpipe.app.viewmodel.settings.ContentSettingsViewModel +import newpipe.shared.generated.resources.Res +import newpipe.shared.generated.resources.app_language_code +import newpipe.shared.generated.resources.app_language_name +import newpipe.shared.generated.resources.app_language_title +import newpipe.shared.generated.resources.content +import newpipe.shared.generated.resources.content_language_title +import newpipe.shared.generated.resources.country_codes +import newpipe.shared.generated.resources.country_names +import newpipe.shared.generated.resources.days +import newpipe.shared.generated.resources.default_content_country_title +import newpipe.shared.generated.resources.feed_fetch_channel_tabs +import newpipe.shared.generated.resources.feed_fetch_channel_tabs_summary +import newpipe.shared.generated.resources.feed_update_threshold_option_always_update +import newpipe.shared.generated.resources.feed_update_threshold_summary +import newpipe.shared.generated.resources.feed_update_threshold_title +import newpipe.shared.generated.resources.feed_use_dedicated_fetch_method_summary +import newpipe.shared.generated.resources.feed_use_dedicated_fetch_method_title +import newpipe.shared.generated.resources.hours +import newpipe.shared.generated.resources.image_quality_high +import newpipe.shared.generated.resources.image_quality_low +import newpipe.shared.generated.resources.image_quality_medium +import newpipe.shared.generated.resources.image_quality_none +import newpipe.shared.generated.resources.image_quality_summary +import newpipe.shared.generated.resources.image_quality_title +import newpipe.shared.generated.resources.language_codes +import newpipe.shared.generated.resources.language_names +import newpipe.shared.generated.resources.local_search_suggestions +import newpipe.shared.generated.resources.main_page_content +import newpipe.shared.generated.resources.main_page_content_summary +import newpipe.shared.generated.resources.minutes +import newpipe.shared.generated.resources.peertube_instance_url_summary +import newpipe.shared.generated.resources.peertube_instance_url_title +import newpipe.shared.generated.resources.remote_search_suggestions +import newpipe.shared.generated.resources.seconds +import newpipe.shared.generated.resources.settings_category_feed_title +import newpipe.shared.generated.resources.show_age_restricted_content_summary +import newpipe.shared.generated.resources.show_age_restricted_content_title +import newpipe.shared.generated.resources.show_channel_tabs +import newpipe.shared.generated.resources.show_channel_tabs_summary +import newpipe.shared.generated.resources.show_comments_summary +import newpipe.shared.generated.resources.show_comments_title +import newpipe.shared.generated.resources.show_description_summary +import newpipe.shared.generated.resources.show_description_title +import newpipe.shared.generated.resources.show_meta_info_summary +import newpipe.shared.generated.resources.show_meta_info_title +import newpipe.shared.generated.resources.show_next_and_similar_title +import newpipe.shared.generated.resources.show_search_suggestions_summary +import newpipe.shared.generated.resources.show_search_suggestions_title +import newpipe.shared.generated.resources.systems_language +import newpipe.shared.generated.resources.thumbnail_cache_wipe_complete_notice +import newpipe.shared.generated.resources.youtube_restricted_mode_enabled_summary +import newpipe.shared.generated.resources.youtube_restricted_mode_enabled_title +import org.jetbrains.compose.resources.StringArrayResource +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringArrayResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel + +private const val SECONDS_PER_MINUTE = 60 +private const val SECONDS_PER_HOUR = 3_600 +private const val SECONDS_PER_DAY = 86_400 + +@Composable +fun ContentSettingsScreen( + navigator: Navigator = koinInject(), + viewModel: ContentSettingsViewModel = koinViewModel(), + contentActions: ContentActions = koinInject() +) { + val appLanguage by viewModel.appLanguage.collectAsStateWithLifecycle() + val contentLanguage by viewModel.contentLanguage.collectAsStateWithLifecycle() + val contentCountry by viewModel.contentCountry.collectAsStateWithLifecycle() + val channelTabs by viewModel.channelTabs.collectAsStateWithLifecycle() + val showAgeRestrictedContent by viewModel.showAgeRestrictedContent + .collectAsStateWithLifecycle() + val youtubeRestrictedMode by viewModel.youtubeRestrictedMode.collectAsStateWithLifecycle() + val searchSuggestions by viewModel.searchSuggestions.collectAsStateWithLifecycle() + val imageQuality by viewModel.imageQuality.collectAsStateWithLifecycle() + val showComments by viewModel.showComments.collectAsStateWithLifecycle() + val showNextVideo by viewModel.showNextVideo.collectAsStateWithLifecycle() + val showDescription by viewModel.showDescription.collectAsStateWithLifecycle() + val showMetaInfo by viewModel.showMetaInfo.collectAsStateWithLifecycle() + val feedUpdateThreshold by viewModel.feedUpdateThreshold.collectAsStateWithLifecycle() + val feedUseDedicatedFetchMethod by viewModel.feedUseDedicatedFetchMethod + .collectAsStateWithLifecycle() + val feedFetchChannelTabs by viewModel.feedFetchChannelTabs.collectAsStateWithLifecycle() + val imageCacheWiped by viewModel.imageCacheWiped.collectAsStateWithLifecycle() + + val systemAppLanguageName = remember { contentActions.systemAppLanguageName() } + + ContentSettingsScreenContent( + appLanguage = appLanguage, + usesSystemAppLanguageSettings = contentActions.usesSystemAppLanguageSettings, + systemAppLanguageName = systemAppLanguageName, + contentLanguage = contentLanguage, + contentCountry = contentCountry, + channelTabs = channelTabs, + showAgeRestrictedContent = showAgeRestrictedContent, + youtubeRestrictedMode = youtubeRestrictedMode, + searchSuggestions = searchSuggestions, + imageQuality = imageQuality, + showComments = showComments, + showNextVideo = showNextVideo, + showDescription = showDescription, + showMetaInfo = showMetaInfo, + feedUpdateThreshold = feedUpdateThreshold, + feedUseDedicatedFetchMethod = feedUseDedicatedFetchMethod, + feedFetchChannelTabs = feedFetchChannelTabs, + imageCacheWiped = imageCacheWiped, + onAppLanguageChange = viewModel::setAppLanguage, + onOpenSystemAppLanguageSettings = contentActions::openSystemAppLanguageSettings, + onContentLanguageChange = viewModel::setContentLanguage, + onContentCountryChange = viewModel::setContentCountry, + onChannelTabsChange = viewModel::setChannelTabs, + onShowAgeRestrictedContentChange = viewModel::setShowAgeRestrictedContent, + onYoutubeRestrictedModeChange = viewModel::setYoutubeRestrictedMode, + onSearchSuggestionsChange = viewModel::setSearchSuggestions, + onImageQualityChange = viewModel::setImageQuality, + onShowCommentsChange = viewModel::setShowComments, + onShowNextVideoChange = viewModel::setShowNextVideo, + onShowDescriptionChange = viewModel::setShowDescription, + onShowMetaInfoChange = viewModel::setShowMetaInfo, + onFeedUpdateThresholdChange = viewModel::setFeedUpdateThreshold, + onFeedUseDedicatedFetchMethodChange = viewModel::setFeedUseDedicatedFetchMethod, + onFeedFetchChannelTabsChange = viewModel::setFeedFetchChannelTabs, + onImageCacheWipedShown = viewModel::onImageCacheWipedShown, + onNavigateUp = { navigator.navigateUp() } + ) +} + +@Composable +fun ContentSettingsScreenContent( + appLanguage: String = DEFAULT_LOCALIZATION, + usesSystemAppLanguageSettings: Boolean = false, + systemAppLanguageName: String? = null, + contentLanguage: String = DEFAULT_LOCALIZATION, + contentCountry: String = DEFAULT_LOCALIZATION, + channelTabs: Set = ChannelTab.SHOW_KEYS, + showAgeRestrictedContent: Boolean = DEFAULT_SHOW_AGE_RESTRICTED_CONTENT, + youtubeRestrictedMode: Boolean = DEFAULT_YOUTUBE_RESTRICTED_MODE, + searchSuggestions: Set = DEFAULT_SEARCH_SUGGESTIONS, + imageQuality: String = DEFAULT_IMAGE_QUALITY, + showComments: Boolean = DEFAULT_SHOW_COMMENTS, + showNextVideo: Boolean = DEFAULT_SHOW_NEXT_VIDEO, + showDescription: Boolean = DEFAULT_SHOW_DESCRIPTION, + showMetaInfo: Boolean = DEFAULT_SHOW_META_INFO, + feedUpdateThreshold: String = DEFAULT_FEED_UPDATE_THRESHOLD, + feedUseDedicatedFetchMethod: Boolean = DEFAULT_FEED_USE_DEDICATED_FETCH_METHOD, + feedFetchChannelTabs: Set = ChannelTab.FETCH_KEYS, + imageCacheWiped: Boolean = false, + onAppLanguageChange: (String) -> Unit = {}, + onOpenSystemAppLanguageSettings: () -> Unit = {}, + onContentLanguageChange: (String) -> Unit = {}, + onContentCountryChange: (String) -> Unit = {}, + onChannelTabsChange: (Set) -> Unit = {}, + onShowAgeRestrictedContentChange: (Boolean) -> Unit = {}, + onYoutubeRestrictedModeChange: (Boolean) -> Unit = {}, + onSearchSuggestionsChange: (Set) -> Unit = {}, + onImageQualityChange: (String) -> Unit = {}, + onShowCommentsChange: (Boolean) -> Unit = {}, + onShowNextVideoChange: (Boolean) -> Unit = {}, + onShowDescriptionChange: (Boolean) -> Unit = {}, + onShowMetaInfoChange: (Boolean) -> Unit = {}, + onFeedUpdateThresholdChange: (String) -> Unit = {}, + onFeedUseDedicatedFetchMethodChange: (Boolean) -> Unit = {}, + onFeedFetchChannelTabsChange: (Set) -> Unit = {}, + onImageCacheWipedShown: () -> Unit = {}, + onNavigateUp: () -> Unit = {} +) { + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val imageCacheWipedText = stringResource(Res.string.thumbnail_cache_wipe_complete_notice) + + LaunchedEffect(imageCacheWiped) { + if (!imageCacheWiped) { + return@LaunchedEffect + } + // Showing the snackbar from the surrounding scope instead of this effect, so that marking + // it as shown below cannot cancel it by restarting the effect + scope.launch { snackbarHostState.showSnackbar(imageCacheWipedText) } + onImageCacheWipedShown() + } + + val systemDefault = stringResource(Res.string.systems_language) + val appLanguageEntries = localeEntries( + systemDefault, + Res.array.app_language_code, + Res.array.app_language_name + ) + val contentLanguageEntries = localeEntries( + systemDefault, + Res.array.language_codes, + Res.array.language_names + ) + val contentCountryEntries = localeEntries( + systemDefault, + Res.array.country_codes, + Res.array.country_names + ) + val channelTabEntries = ChannelTab.entries.map { tab -> + ListPreferenceEntry(tab.showKey, stringResource(tab.title)) + } + val feedFetchChannelTabEntries = ChannelTab.entries.mapNotNull { tab -> + tab.fetchKey?.let { key -> ListPreferenceEntry(key, stringResource(tab.title)) } + } + val searchSuggestionEntries = listOf( + ListPreferenceEntry( + SEARCH_SUGGESTIONS_LOCAL, + stringResource(Res.string.local_search_suggestions) + ), + ListPreferenceEntry( + SEARCH_SUGGESTIONS_REMOTE, + stringResource(Res.string.remote_search_suggestions) + ) + ) + val imageQualityEntries = listOf( + ListPreferenceEntry(IMAGE_QUALITY_NONE, stringResource(Res.string.image_quality_none)), + ListPreferenceEntry(IMAGE_QUALITY_LOW, stringResource(Res.string.image_quality_low)), + ListPreferenceEntry(IMAGE_QUALITY_MEDIUM, stringResource(Res.string.image_quality_medium)), + ListPreferenceEntry(IMAGE_QUALITY_HIGH, stringResource(Res.string.image_quality_high)) + ) + val feedUpdateThresholdEntries = FEED_UPDATE_THRESHOLDS.map { seconds -> + ListPreferenceEntry(seconds, feedUpdateThresholdLabel(seconds.toInt())) + } + + fun selectedTitle(entries: List, value: String) = entries.firstOrNull { it.value == value }?.title.orEmpty() + + Scaffold( + topBar = { + TopAppBar( + title = stringResource(Res.string.content), + onNavigateUp = onNavigateUp + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding()) + .verticalScroll(rememberScrollState()) + .padding(WindowInsets.navigationBars.asPaddingValues()) + ) { + if (usesSystemAppLanguageSettings) { + PreferenceRow( + title = stringResource(Res.string.app_language_title), + summary = systemAppLanguageName ?: systemDefault, + onClick = onOpenSystemAppLanguageSettings + ) + } else { + ListPreference( + title = stringResource(Res.string.app_language_title), + entries = appLanguageEntries, + selectedValue = appLanguage, + onValueSelected = onAppLanguageChange + ) + } + ListPreference( + title = stringResource(Res.string.content_language_title), + entries = contentLanguageEntries, + selectedValue = contentLanguage, + onValueSelected = onContentLanguageChange + ) + ListPreference( + title = stringResource(Res.string.default_content_country_title), + entries = contentCountryEntries, + selectedValue = contentCountry, + onValueSelected = onContentCountryChange + ) + // TODO: open the main page content screen once it is migrated + PreferenceRow( + title = stringResource(Res.string.main_page_content), + summary = stringResource(Res.string.main_page_content_summary) + ) + MultiSelectListPreference( + title = stringResource(Res.string.show_channel_tabs), + summary = stringResource(Res.string.show_channel_tabs_summary), + entries = channelTabEntries, + selectedValues = channelTabs, + onValuesSelected = onChannelTabsChange + ) + // TODO: open the PeerTube instances screen once it is migrated + PreferenceRow( + title = stringResource(Res.string.peertube_instance_url_title), + summary = stringResource(Res.string.peertube_instance_url_summary) + ) + SwitchPreference( + title = stringResource(Res.string.show_age_restricted_content_title), + summary = stringResource(Res.string.show_age_restricted_content_summary), + checked = showAgeRestrictedContent, + onCheckedChange = onShowAgeRestrictedContentChange + ) + SwitchPreference( + title = stringResource(Res.string.youtube_restricted_mode_enabled_title), + summary = stringResource(Res.string.youtube_restricted_mode_enabled_summary), + checked = youtubeRestrictedMode, + onCheckedChange = onYoutubeRestrictedModeChange + ) + MultiSelectListPreference( + title = stringResource(Res.string.show_search_suggestions_title), + summary = stringResource(Res.string.show_search_suggestions_summary), + entries = searchSuggestionEntries, + selectedValues = searchSuggestions, + onValuesSelected = onSearchSuggestionsChange + ) + ListPreference( + title = stringResource(Res.string.image_quality_title), + summary = stringResource( + Res.string.image_quality_summary, + selectedTitle(imageQualityEntries, imageQuality) + ), + entries = imageQualityEntries, + selectedValue = imageQuality, + onValueSelected = onImageQualityChange + ) + SwitchPreference( + title = stringResource(Res.string.show_comments_title), + summary = stringResource(Res.string.show_comments_summary), + checked = showComments, + onCheckedChange = onShowCommentsChange + ) + SwitchPreference( + title = stringResource(Res.string.show_next_and_similar_title), + checked = showNextVideo, + onCheckedChange = onShowNextVideoChange + ) + SwitchPreference( + title = stringResource(Res.string.show_description_title), + summary = stringResource(Res.string.show_description_summary), + checked = showDescription, + onCheckedChange = onShowDescriptionChange + ) + SwitchPreference( + title = stringResource(Res.string.show_meta_info_title), + summary = stringResource(Res.string.show_meta_info_summary), + checked = showMetaInfo, + onCheckedChange = onShowMetaInfoChange + ) + + PreferenceCategoryTitle(title = stringResource(Res.string.settings_category_feed_title)) + + ListPreference( + title = stringResource(Res.string.feed_update_threshold_title), + summary = stringResource( + Res.string.feed_update_threshold_summary, + selectedTitle(feedUpdateThresholdEntries, feedUpdateThreshold) + ), + entries = feedUpdateThresholdEntries, + selectedValue = feedUpdateThreshold, + onValueSelected = onFeedUpdateThresholdChange + ) + SwitchPreference( + title = stringResource(Res.string.feed_use_dedicated_fetch_method_title), + summary = stringResource(Res.string.feed_use_dedicated_fetch_method_summary), + checked = feedUseDedicatedFetchMethod, + onCheckedChange = onFeedUseDedicatedFetchMethodChange + ) + MultiSelectListPreference( + title = stringResource(Res.string.feed_fetch_channel_tabs), + summary = stringResource(Res.string.feed_fetch_channel_tabs_summary), + entries = feedFetchChannelTabEntries, + selectedValues = feedFetchChannelTabs, + onValuesSelected = onFeedFetchChannelTabsChange + ) + } + } +} + +/** + * Pairs the codes and names of a language or country list, led by the system default entry + */ +@Composable +private fun localeEntries( + systemDefault: String, + codes: StringArrayResource, + names: StringArrayResource +): List = listOf(ListPreferenceEntry(DEFAULT_LOCALIZATION, systemDefault)) + + stringArrayResource(codes).zip(stringArrayResource(names), ::ListPreferenceEntry) + +/** + * Names a feed update threshold by its largest whole unit, as the old DurationListPreference did. + */ +@Composable +private fun feedUpdateThresholdLabel(seconds: Int): String = when { + seconds <= 0 -> stringResource(Res.string.feed_update_threshold_option_always_update) + + seconds >= SECONDS_PER_DAY -> { + val days = seconds / SECONDS_PER_DAY + pluralStringResource(Res.plurals.days, days, days) + } + + seconds >= SECONDS_PER_HOUR -> { + val hours = seconds / SECONDS_PER_HOUR + pluralStringResource(Res.plurals.hours, hours, hours) + } + + seconds >= SECONDS_PER_MINUTE -> { + val minutes = seconds / SECONDS_PER_MINUTE + pluralStringResource(Res.plurals.minutes, minutes, minutes) + } + + else -> pluralStringResource(Res.plurals.seconds, seconds, seconds) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@PreviewLightDark +@Composable +private fun ContentSettingsScreenPreview() { + ContentSettingsScreenContent() +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/ContentSettingsViewModel.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/ContentSettingsViewModel.kt new file mode 100644 index 00000000000..dd773ee84c2 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/ContentSettingsViewModel.kt @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.viewmodel.settings + +import androidx.lifecycle.ViewModel +import com.russhwolf.settings.Settings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import net.newpipe.app.platform.ContentActions +import net.newpipe.app.platform.StringSetStore +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_FEED_UPDATE_THRESHOLD +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_FEED_USE_DEDICATED_FETCH_METHOD +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_IMAGE_QUALITY +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_LOCALIZATION +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SEARCH_SUGGESTIONS +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SHOW_AGE_RESTRICTED_CONTENT +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SHOW_COMMENTS +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SHOW_DESCRIPTION +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SHOW_META_INFO +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_SHOW_NEXT_VIDEO +import net.newpipe.app.preferences.ContentPreferences.DEFAULT_YOUTUBE_RESTRICTED_MODE +import net.newpipe.app.preferences.ContentPreferences.KEY_APP_LANGUAGE +import net.newpipe.app.preferences.ContentPreferences.KEY_CHANNEL_TABS +import net.newpipe.app.preferences.ContentPreferences.KEY_CONTENT_COUNTRY +import net.newpipe.app.preferences.ContentPreferences.KEY_CONTENT_LANGUAGE +import net.newpipe.app.preferences.ContentPreferences.KEY_FEED_FETCH_CHANNEL_TABS +import net.newpipe.app.preferences.ContentPreferences.KEY_FEED_UPDATE_THRESHOLD +import net.newpipe.app.preferences.ContentPreferences.KEY_FEED_USE_DEDICATED_FETCH_METHOD +import net.newpipe.app.preferences.ContentPreferences.KEY_IMAGE_QUALITY +import net.newpipe.app.preferences.ContentPreferences.KEY_SEARCH_SUGGESTIONS +import net.newpipe.app.preferences.ContentPreferences.KEY_SHOW_AGE_RESTRICTED_CONTENT +import net.newpipe.app.preferences.ContentPreferences.KEY_SHOW_COMMENTS +import net.newpipe.app.preferences.ContentPreferences.KEY_SHOW_DESCRIPTION +import net.newpipe.app.preferences.ContentPreferences.KEY_SHOW_META_INFO +import net.newpipe.app.preferences.ContentPreferences.KEY_SHOW_NEXT_VIDEO +import net.newpipe.app.preferences.ContentPreferences.KEY_YOUTUBE_RESTRICTED_MODE +import net.newpipe.app.screen.settings.model.ChannelTab +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +class ContentSettingsViewModel( + private val settings: Settings, + private val stringSetStore: StringSetStore, + private val contentActions: ContentActions +) : ViewModel() { + + val appLanguage: StateFlow + field = stringFlow(KEY_APP_LANGUAGE, DEFAULT_LOCALIZATION) + + val contentLanguage: StateFlow + field = stringFlow(KEY_CONTENT_LANGUAGE, DEFAULT_LOCALIZATION) + + val contentCountry: StateFlow + field = stringFlow(KEY_CONTENT_COUNTRY, DEFAULT_LOCALIZATION) + + val channelTabs: StateFlow> + field = stringSetFlow(KEY_CHANNEL_TABS, ChannelTab.SHOW_KEYS) + + val showAgeRestrictedContent: StateFlow + field = booleanFlow(KEY_SHOW_AGE_RESTRICTED_CONTENT, DEFAULT_SHOW_AGE_RESTRICTED_CONTENT) + + val youtubeRestrictedMode: StateFlow + field = booleanFlow(KEY_YOUTUBE_RESTRICTED_MODE, DEFAULT_YOUTUBE_RESTRICTED_MODE) + + val searchSuggestions: StateFlow> + field = stringSetFlow(KEY_SEARCH_SUGGESTIONS, DEFAULT_SEARCH_SUGGESTIONS) + + val imageQuality: StateFlow + field = stringFlow(KEY_IMAGE_QUALITY, DEFAULT_IMAGE_QUALITY) + + val showComments: StateFlow + field = booleanFlow(KEY_SHOW_COMMENTS, DEFAULT_SHOW_COMMENTS) + + val showNextVideo: StateFlow + field = booleanFlow(KEY_SHOW_NEXT_VIDEO, DEFAULT_SHOW_NEXT_VIDEO) + + val showDescription: StateFlow + field = booleanFlow(KEY_SHOW_DESCRIPTION, DEFAULT_SHOW_DESCRIPTION) + + val showMetaInfo: StateFlow + field = booleanFlow(KEY_SHOW_META_INFO, DEFAULT_SHOW_META_INFO) + + val feedUpdateThreshold: StateFlow + field = stringFlow(KEY_FEED_UPDATE_THRESHOLD, DEFAULT_FEED_UPDATE_THRESHOLD) + + val feedUseDedicatedFetchMethod: StateFlow + field = booleanFlow(KEY_FEED_USE_DEDICATED_FETCH_METHOD, DEFAULT_FEED_USE_DEDICATED_FETCH_METHOD) + + val feedFetchChannelTabs: StateFlow> + field = stringSetFlow(KEY_FEED_FETCH_CHANNEL_TABS, ChannelTab.FETCH_KEYS) + + val imageCacheWiped: StateFlow + field = MutableStateFlow(false) + + fun setAppLanguage(value: String) { + appLanguage.persist(KEY_APP_LANGUAGE, value) + contentActions.applyAppLanguage(value) + } + + fun setContentLanguage(value: String) = contentLanguage.persist(KEY_CONTENT_LANGUAGE, value) + + fun setContentCountry(value: String) = contentCountry.persist(KEY_CONTENT_COUNTRY, value) + + fun setChannelTabs(value: Set) = channelTabs.persist(KEY_CHANNEL_TABS, value) + + fun setShowAgeRestrictedContent(value: Boolean) = showAgeRestrictedContent + .persist(KEY_SHOW_AGE_RESTRICTED_CONTENT, value) + + fun setYoutubeRestrictedMode(value: Boolean) { + youtubeRestrictedMode.persist(KEY_YOUTUBE_RESTRICTED_MODE, value) + contentActions.applyYoutubeRestrictedMode(value) + } + + fun setSearchSuggestions(value: Set) = searchSuggestions + .persist(KEY_SEARCH_SUGGESTIONS, value) + + fun setImageQuality(value: String) { + imageQuality.persist(KEY_IMAGE_QUALITY, value) + contentActions.applyImageQuality(value) + imageCacheWiped.value = true + } + + fun setShowComments(value: Boolean) = showComments.persist(KEY_SHOW_COMMENTS, value) + + fun setShowNextVideo(value: Boolean) = showNextVideo.persist(KEY_SHOW_NEXT_VIDEO, value) + + fun setShowDescription(value: Boolean) = showDescription.persist(KEY_SHOW_DESCRIPTION, value) + + fun setShowMetaInfo(value: Boolean) = showMetaInfo.persist(KEY_SHOW_META_INFO, value) + + fun setFeedUpdateThreshold(value: String) = feedUpdateThreshold + .persist(KEY_FEED_UPDATE_THRESHOLD, value) + + fun setFeedUseDedicatedFetchMethod(value: Boolean) = feedUseDedicatedFetchMethod + .persist(KEY_FEED_USE_DEDICATED_FETCH_METHOD, value) + + fun setFeedFetchChannelTabs(value: Set) = feedFetchChannelTabs + .persist(KEY_FEED_FETCH_CHANNEL_TABS, value) + + fun onImageCacheWipedShown() { + imageCacheWiped.value = false + } + + override fun onCleared() = contentActions.applyContentLocalization() + + private fun stringFlow(key: String, defaultValue: String) = MutableStateFlow( + settings.getString(key, defaultValue) + ) + + private fun booleanFlow(key: String, defaultValue: Boolean) = MutableStateFlow( + settings.getBoolean(key, defaultValue) + ) + + private fun stringSetFlow(key: String, defaultValue: Set) = MutableStateFlow( + stringSetStore.get(key, defaultValue) + ) + + private fun MutableStateFlow.persist(key: String, newValue: String) { + settings.putString(key, newValue) + value = newValue + } + + private fun MutableStateFlow.persist(key: String, newValue: Boolean) { + settings.putBoolean(key, newValue) + value = newValue + } + + private fun MutableStateFlow>.persist(key: String, newValue: Set) { + stringSetStore.put(key, newValue) + value = newValue + } +} From 6caac15ac95c69f9a36b44d7c7618b592121b562 Mon Sep 17 00:00:00 2001 From: Ida Delphine Date: Mon, 26 Oct 2026 13:10:01 -0700 Subject: [PATCH 7/8] set up navigation --- .../kotlin/net/newpipe/app/navigation/Destination.kt | 3 +++ .../kotlin/net/newpipe/app/navigation/NavModule.kt | 5 +++++ .../net/newpipe/app/screen/settings/SettingsHomeScreen.kt | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Destination.kt b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Destination.kt index 4d38b2ae0ee..9bedda404d9 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Destination.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Destination.kt @@ -14,6 +14,9 @@ import kotlinx.serialization.Serializable @Serializable sealed interface Destination : NavKey { + @Serializable + data object ContentSettings : Destination + @Serializable data object HistorySettings : Destination diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavModule.kt b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavModule.kt index 15ea33ecafe..5215a030db8 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavModule.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavModule.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateListOf import co.touchlab.kermit.Logger import net.newpipe.app.screen.about.AboutScreen import net.newpipe.app.screen.settings.AppearanceSettingsScreen +import net.newpipe.app.screen.settings.ContentSettingsScreen import net.newpipe.app.screen.settings.DebugSettingsScreen import net.newpipe.app.screen.settings.DownloadSettingsScreen import net.newpipe.app.screen.settings.HistorySettingsScreen @@ -62,6 +63,10 @@ fun navModule() = module { navigation { HistorySettingsScreen() } + + navigation { + ContentSettingsScreen() + } } /** diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/SettingsHomeScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/SettingsHomeScreen.kt index 61a13247238..1e5dac083f2 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/SettingsHomeScreen.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/SettingsHomeScreen.kt @@ -72,6 +72,10 @@ fun SettingsHomeScreen( Destination.HistorySettings ) + SettingsCategoryType.CONTENT -> navigator.navigateTo( + Destination.ContentSettings + ) + // TODO: wire remaining sub-screens as they are migrated else -> Unit } From 42c6fb2700b38ee5aafbfa1911aa8dde5bb0c35a Mon Sep 17 00:00:00 2001 From: Ida Delphine Date: Wed, 28 Oct 2026 10:45:08 -0700 Subject: [PATCH 8/8] add content settings tests --- .../screen/settings/ContentSettingsScreen.kt | 3 + .../settings/ContentSettingsScreenTest.kt | 100 ++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 shared/src/commonTest/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreenTest.kt diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreen.kt index 7b3b331af5d..0639f76cd28 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreen.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -395,6 +396,8 @@ fun ContentSettingsScreenContent( onCheckedChange = onShowMetaInfoChange ) + HorizontalDivider() + PreferenceCategoryTitle(title = stringResource(Res.string.settings_category_feed_title)) ListPreference( diff --git a/shared/src/commonTest/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreenTest.kt b/shared/src/commonTest/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreenTest.kt new file mode 100644 index 00000000000..ebaaff73679 --- /dev/null +++ b/shared/src/commonTest/kotlin/net/newpipe/app/screen/settings/ContentSettingsScreenTest.kt @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.screen.settings + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.v2.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import net.newpipe.app.extensions.settingsModule +import net.newpipe.app.extensions.withKoin +import net.newpipe.app.preferences.ContentPreferences.IMAGE_QUALITY_HIGH +import net.newpipe.app.screen.settings.model.ChannelTab +import newpipe.shared.generated.resources.Res +import newpipe.shared.generated.resources.channel_tab_videos +import newpipe.shared.generated.resources.content +import newpipe.shared.generated.resources.content_language_title +import newpipe.shared.generated.resources.image_quality_high +import newpipe.shared.generated.resources.image_quality_summary +import newpipe.shared.generated.resources.ok +import newpipe.shared.generated.resources.show_channel_tabs +import org.jetbrains.compose.resources.getString + +@OptIn(ExperimentalTestApi::class) +class ContentSettingsScreenTest { + + @Test + fun rendersTitleAndContentLanguageRow() = runComposeUiTest { + withKoin( + modules = listOf(settingsModule()), + content = { ContentSettingsScreenContent() }, + onContent = { + onNodeWithText(getString(Res.string.content)).assertIsDisplayed() + onNodeWithText(getString(Res.string.content_language_title)).assertIsDisplayed() + } + ) + } + + @Test + fun appLanguageRowOpensSystemSettingsWhenTheSystemOwnsIt() = runComposeUiTest { + var opened = false + withKoin( + modules = listOf(settingsModule()), + content = { + ContentSettingsScreenContent( + usesSystemAppLanguageSettings = true, + systemAppLanguageName = "Deutsch", + onOpenSystemAppLanguageSettings = { opened = true } + ) + }, + onContent = { + onNodeWithText("Deutsch").performClick() + assertTrue(opened) + } + ) + } + + @Test + fun channelTabsAreOnlySavedOnConfirm() = runComposeUiTest { + var saved: Set? = null + withKoin( + modules = listOf(settingsModule()), + content = { + ContentSettingsScreenContent(onChannelTabsChange = { value -> saved = value }) + }, + onContent = { + onNodeWithText(getString(Res.string.show_channel_tabs)) + .performScrollTo() + .performClick() + onNodeWithText(getString(Res.string.channel_tab_videos)).performClick() + assertNull(saved) + + onNodeWithText(getString(Res.string.ok)).performClick() + assertEquals(ChannelTab.SHOW_KEYS - ChannelTab.VIDEOS.showKey, saved) + } + ) + } + + @Test + fun imageQualitySummaryNamesTheSelectedQuality() = runComposeUiTest { + withKoin( + modules = listOf(settingsModule()), + content = { ContentSettingsScreenContent(imageQuality = IMAGE_QUALITY_HIGH) }, + onContent = { + val quality = getString(Res.string.image_quality_high) + onNodeWithText(getString(Res.string.image_quality_summary, quality)) + .performScrollTo() + .assertIsDisplayed() + } + ) + } +}