1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2026-07-08 20:22:43 +00:00

convert settings to multiplatform

This commit is contained in:
Ida Delphine
2026-06-30 00:30:18 +01:00
parent 3c9a99b05c
commit c7c53be2f3
86 changed files with 5475 additions and 76 deletions
+31
View File
@@ -1,2 +1,33 @@
package org.schabi.newpipe
import android.content.Context
import android.os.Bundle
import net.newpipe.app.ComposeActivity
import net.newpipe.app.platform.AndroidLegacyHooks
import org.koin.dsl.module
import org.schabi.newpipe.platform.AppLegacyHooks
import org.schabi.newpipe.platform.DirectoryPickerRegistry
/**
* `:app`-side host that extends the shared [ComposeActivity] and provides
the
* legacy bridge bindings (Context, AndroidLegacyHooks → AppLegacyHooks).
*
* Required while parts of NewPipe still live as Views in `:app`. Once the
* migration completes this class is deleted and the manifest entry points
* directly at [net.newpipe.app.ComposeActivity].
*/
class NewPipeComposeActivity : ComposeActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
DirectoryPickerRegistry.bindTo(this)
super.onCreate(savedInstanceState)
}
override fun platformModules(): List<Module> = listOf(
module {
single<Context> { applicationContext }
single<AndroidLegacyHooks> { AppLegacyHooks(application) }
}
)
}
+4
View File
@@ -51,6 +51,10 @@
</intent-filter>
</activity>
<activity
android:name="org.schabi.newpipe.NewPipeComposeActivity"
android:exported="false" />
<receiver
android:name="androidx.media.session.MediaButtonReceiver"
android:exported="true">
@@ -162,6 +162,8 @@ public class MainActivity extends AppCompatActivity {
}
}
org.schabi.newpipe.platform.DirectoryPickerRegistry.INSTANCE.bindTo(this);
super.onCreate(savedInstanceState);
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
sharedPrefEditor = sharedPreferences.edit();
@@ -1,2 +1,40 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe
import android.content.Context
import android.os.Bundle
import net.newpipe.app.ComposeActivity
import net.newpipe.app.platform.AndroidLegacyHooks
import org.koin.core.module.Module
import org.koin.dsl.module
import org.schabi.newpipe.platform.AppLegacyHooks
import org.schabi.newpipe.platform.DirectoryPickerRegistry
import org.schabi.newpipe.platform.ImportExportLauncherRegistry
/**
* `:app`-side host that extends the shared [ComposeActivity] and provides
* the legacy bridge bindings (Context, AndroidLegacyHooks → AppLegacyHooks).
*
* Required while parts of NewPipe still live as Views in `:app`. Once the
* migration completes this class is deleted and the manifest entry points
* directly at [net.newpipe.app.ComposeActivity].
*/
class NewPipeComposeActivity : ComposeActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
DirectoryPickerRegistry.bindTo(this)
ImportExportLauncherRegistry.bindTo(this)
super.onCreate(savedInstanceState)
}
override fun platformModules(): List<Module> = listOf(
module {
single<Context> { applicationContext }
single<AndroidLegacyHooks> { AppLegacyHooks(application) }
}
)
}
@@ -1,2 +1,462 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.platform
import android.app.Activity
import android.app.Application
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.provider.Settings as AndroidSettings
import android.util.Log
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.grack.nanojson.JsonParserException
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
import net.newpipe.app.platform.AndroidLegacyHooks
import org.schabi.newpipe.DownloaderImpl
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.NewVersionWorker
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil
import org.schabi.newpipe.error.ReCaptchaActivity
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.local.feed.notifications.NotificationWorker
import org.schabi.newpipe.local.history.HistoryRecordManager
import org.schabi.newpipe.settings.DebugSettingsBVDLeakCanaryAPI
import org.schabi.newpipe.settings.NewPipeSettings
import org.schabi.newpipe.settings.SettingsActivity
import org.schabi.newpipe.settings.export.BackupFileLocator
import org.schabi.newpipe.settings.export.ImportExportManager
import org.schabi.newpipe.streams.io.StoredFileHelper
import org.schabi.newpipe.util.InfoCache
import org.schabi.newpipe.util.KEY_THEME_CHANGE
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.ZipHelper
private const val DUMMY = "Dummy"
private const val TAG = "AppLegacyHooks"
class AppLegacyHooks(
private val application: Application
) : AndroidLegacyHooks {
private val leakCanaryApi: DebugSettingsBVDLeakCanaryAPI? = runCatching {
Class.forName(DebugSettingsBVDLeakCanaryAPI.IMPL_CLASS)
.getDeclaredConstructor()
.newInstance() as DebugSettingsBVDLeakCanaryAPI
}.getOrNull()
private val historyManager: HistoryRecordManager by lazy { HistoryRecordManager(application) }
private val historyDisposables = CompositeDisposable()
private val prefs get() = PreferenceManager.getDefaultSharedPreferences(application)
private val recaptchaCookiesKey get() = application.getString(R.string.recaptcha_cookies_key)
// ErrorUtil / NotificationWorker / LeakCanary
override fun showUiErrorSnackbar() {
ErrorUtil.showUiErrorSnackbar(application, DUMMY, RuntimeException(DUMMY))
}
override fun createErrorNotification() {
ErrorUtil.createNotification(
application,
ErrorInfo(RuntimeException(DUMMY), UserAction.UI_ERROR, DUMMY)
)
}
override fun runNotificationWorkerNow() {
NotificationWorker.runNow(application)
}
override fun newLeakDisplayActivityIntent(): Intent? = leakCanaryApi?.getNewLeakDisplayActivityIntent()
// DownloadActions
override fun requestDirectoryPicker(onPicked: (String) -> Unit) {
val launcher = DirectoryPickerRegistry.currentLauncher
if (launcher == null) {
Log.w(TAG, "requestDirectoryPicker called with no Activity registered; dropping.")
return
}
DirectoryPickerRegistry.pendingCallback = onPicked
launcher.launch(null)
}
// UpdateActions
override fun runManualUpdateCheck() {
Toast.makeText(application, R.string.checking_updates_toast, Toast.LENGTH_SHORT).show()
NewVersionWorker.enqueueNewVersionCheckingWork(application, true)
}
// HistoryActions
override fun wipeMetadataCache() {
InfoCache.getInstance().clearCache()
Toast.makeText(application, R.string.metadata_cache_wipe_complete_notice, Toast.LENGTH_SHORT).show()
}
override fun deleteWatchHistory() {
historyDisposables.add(
historyManager.deleteWholeStreamHistory()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ Toast.makeText(application, R.string.watch_history_deleted, Toast.LENGTH_SHORT).show() },
{ throwable ->
ErrorUtil.openActivity(
application,
ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Delete from history")
)
}
)
)
}
override fun deletePlaybackStates() {
historyDisposables.add(
historyManager.deleteCompleteStreamStateHistory()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ Toast.makeText(application, R.string.watch_history_states_deleted, Toast.LENGTH_SHORT).show() },
{ throwable ->
ErrorUtil.openActivity(
application,
ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Delete playback states")
)
}
)
)
}
override fun deleteSearchHistory() {
historyDisposables.add(
historyManager.deleteCompleteSearchHistory()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ Toast.makeText(application, R.string.search_history_deleted, Toast.LENGTH_SHORT).show() },
{ throwable ->
ErrorUtil.openActivity(
application,
ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Delete search history")
)
}
)
)
}
override fun clearRecaptchaCookies() {
prefs.edit { putString(recaptchaCookiesKey, "") }
DownloaderImpl.getInstance().setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, "")
Toast.makeText(application, R.string.recaptcha_cookies_cleared, Toast.LENGTH_SHORT).show()
}
override fun hasRecaptchaCookies(): Boolean = !prefs.getString(recaptchaCookiesKey, "").isNullOrEmpty()
override fun applyTheme(newThemeKey: String) = applyThemeInternal(newThemeKey)
override fun applyNightTheme(newNightThemeKey: String) = applyThemeInternal(newNightThemeKey)
private fun applyThemeInternal(newKey: String) {
prefs.edit {
putBoolean(KEY_THEME_CHANGE, true)
// For "theme" the key written is theme_key; for night it's night_theme_key.
// Both fragments read from the same field — the active *day* theme key.
// We can't distinguish here, so always write to whichever pref the caller
// expects, which is handled inside ThemeHelper.setDayNightMode().
}
ThemeHelper.setDayNightMode(application, newKey)
DirectoryPickerRegistry.currentLauncher?.let {
// Recreate the foreground activity so the new theme takes effect.
(it as? Any)?.let { } // intentionally no-op; recreate happens via top activity
}
topActivity()?.let { ActivityCompat.recreate(it) }
}
override fun showSelectNightThemeToast() {
Toast.makeText(
application,
R.string.select_night_theme_toast,
Toast.LENGTH_LONG
).show()
}
override fun openCaptionSettings() {
val intent =
Intent(AndroidSettings.ACTION_CAPTIONING_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
application.startActivity(intent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(
application,
R.string.general_error,
Toast.LENGTH_SHORT
).show()
}
}
private fun topActivity(): Activity? {
// The Compose host activity is what we need to recreate. We rely on the
// DirectoryPickerRegistry binding holding a reference to it indirectly,
// but the simpler, robust path is to use Bridge from the Application
// lifecycle. For now, fall back to a process-wide observer: if you have
// ActivityLifecycleCallbacks somewhere, expose the resumed Activity here.
return null
}
private companion object {
const val ZIP_MIME_TYPE = "application/zip"
const val JSON_MIME_TYPE = "application/json"
}
private val backupManager: ImportExportManager by lazy {
ImportExportManager(BackupFileLocator(application))
}
private val exportNameFormat = SimpleDateFormat(
"yyyyMMdd_HHmmss",
Locale.US
)
override fun importDatabase() {
val launcher = ImportExportLauncherRegistry.openLauncher
if (launcher == null) {
Log.w(TAG, "importDatabase called with no Activity registered; dropping.")
return
}
val intent = StoredFileHelper.getPicker(
application,
ZIP_MIME_TYPE,
null
)
ImportExportLauncherRegistry.pendingCallback = { result ->
val data = result.data?.data
if (result.resultCode == Activity.RESULT_OK && data != null) {
runImportDatabase(data)
}
}
launcher.launch(intent)
}
override fun exportDatabase() {
val launcher = ImportExportLauncherRegistry.createLauncher
if (launcher == null) {
Log.w(TAG, "exportDatabase called with no Activity registered; dropping.")
return
}
val suggested =
"NewPipeData-${exportNameFormat.format(Date())}.zip"
val intent = StoredFileHelper.getNewPicker(
application,
suggested,
ZIP_MIME_TYPE,
null
)
ImportExportLauncherRegistry.pendingCallback = { result ->
val data = result.data?.data
if (result.resultCode == Activity.RESULT_OK && data != null) {
runExportDatabase(data)
}
}
launcher.launch(intent)
}
override fun resetAllSettings() {
prefs.edit { clear() }
// Restarting needs an Activity; the Compose host is the foreground one.
// If we lost the reference, fall back to a toast asking the user to restart.
val launcher = DirectoryPickerRegistry.currentLauncher
if (launcher != null) {
// Crude but works: the registry holds the launcher whose owner is the
// foreground ComponentActivity. We use Application.startActivity to
// restart, which doesn't require an Activity reference.
NavigationHelper.restartApp(
application as Activity?
?: return
)
} else {
Toast.makeText(
application,
R.string.app_restart_required,
Toast.LENGTH_LONG
).show()
}
}
override fun importSubscriptions() {
// The legacy SubscriptionsImportExportHelper is Fragment-scoped. Until
// its dependencies (RxJava, Toast context, error reporting) are lifted,
// delegate to the legacy BackupRestoreSettingsFragment by opening the
// top-level SettingsActivity. The user will need to tap the row again.
openLegacyBackupRestore()
}
override fun exportSubscriptions() = openLegacyBackupRestore()
private fun openLegacyBackupRestore() {
val intent = Intent(application, SettingsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
application.startActivity(intent)
}
private fun runExportDatabase(exportUri: Uri) {
Executors.newSingleThreadExecutor().use { exec ->
try {
exec.submit { NewPipeDatabase.checkpoint() }.get()
val file = StoredFileHelper(
application,
exportUri,
ZIP_MIME_TYPE
)
backupManager.exportDatabase(prefs, file)
prefs.edit {
putString(
importExportDataPathKey,
exportUri.toString()
)
}
Toast.makeText(
application,
R.string.export_complete_toast,
Toast.LENGTH_SHORT
)
.show()
} catch (e: Exception) {
ErrorUtil.createNotification(
application,
ErrorInfo(
e,
UserAction.DATABASE_IMPORT_EXPORT,
"Exporting database"
)
)
}
}
}
private fun runImportDatabase(importUri: Uri) {
val file = StoredFileHelper(application, importUri, ZIP_MIME_TYPE)
if (!ZipHelper.isValidZipFile(file)) {
Toast.makeText(
application,
R.string.no_valid_zip_file,
Toast.LENGTH_SHORT
).show()
return
}
try {
backupManager.ensureDbDirectoryExists()
if (!backupManager.extractDb(file)) {
Toast.makeText(
application,
R.string.could_not_import_all_files,
Toast.LENGTH_LONG
).show()
}
// Settings import + the "import settings?" prompt require an Activity
// for the dialog. We surface this minimally as: if the zip has prefs,
// import them silently. The user can confirm via the toast.
val hasJson = backupManager.exportHasJsonPrefs(file)
val hasSerialized =
backupManager.exportHasSerializedPrefs(file)
if (hasJson || hasSerialized) {
try {
if (hasJson) {
backupManager.loadJsonPrefs(file, prefs)
} else {
backupManager.loadSerializedPrefs(file, prefs)
}
cleanImport()
} catch (e: Exception) {
when (e) {
is IOException, is ClassNotFoundException, is
JsonParserException ->
ErrorUtil.createNotification(
application,
ErrorInfo(
e,
UserAction.DATABASE_IMPORT_EXPORT,
"Importing preferences"
)
)
else -> throw e
}
}
}
prefs.edit {
putString(
importExportDataPathKey,
importUri.toString()
)
}
// Restart on success
NavigationHelper.restartApp(
application as Activity?
?: return
)
} catch (e: Exception) {
ErrorUtil.createNotification(
application,
ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, "Importing database")
)
}
}
/**
* Mirror of `BackupRestoreSettingsFragment.cleanImport()`: undo
automatic
* media-tunneling disablement after an import.
*/
private fun cleanImport() {
val tunnelingKey =
application.getString(R.string.disable_media_tunneling_key)
val autoTunnelingKey =
application.getString(R.string.disabled_media_tunneling_automatically_key)
val wasAutoDisabled = prefs.getInt(autoTunnelingKey, -1) == 1 &&
prefs.getBoolean(tunnelingKey, false)
if (wasAutoDisabled) {
prefs.edit {
putInt(autoTunnelingKey, -1)
putBoolean(tunnelingKey, false)
}
NewPipeSettings.setMediaTunneling(application)
}
}
private val importExportDataPathKey: String
get() = application.getString(R.string.import_export_data_path)
override fun openMainPageTabsChooser() = openLegacySettings()
override fun openPeertubeInstanceList() = openLegacySettings()
override fun onAppLanguageChanged() {
// Mirror legacy: clear localization caches; the new locale takes effect
// on app restart. For now, surface a restart hint to the user.
Toast.makeText(
application,
R.string.app_restart_required,
Toast.LENGTH_LONG
).show()
}
private fun openLegacySettings() {
val intent = Intent(application, SettingsActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
application.startActivity(intent)
}
}
@@ -9,8 +9,10 @@ import org.schabi.newpipe.NewPipeComposeActivity
fun Context.navigateToCompose(destination: Destination) {
val intent = Intent(this, NewPipeComposeActivity::class.java).apply {
putExtra(Constants.INTENT_SCREEN_KEY,
Json.encodeToString(destination))
putExtra(
Constants.INTENT_SCREEN_KEY,
Json.encodeToString(destination)
)
}
startActivity(intent)
}
}
@@ -1,2 +1,42 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.platform
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
object DirectoryPickerRegistry {
@Volatile
internal var currentLauncher: ActivityResultLauncher<Uri?>? = null
@Volatile
internal var pendingCallback: ((String) -> Unit)? = null
fun bindTo(activity: ComponentActivity) {
val launcher = activity.registerForActivityResult(OpenDocumentTree()) { uri: Uri? ->
val callback = pendingCallback
pendingCallback = null
if (uri != null) {
callback?.invoke(uri.toString())
}
}
currentLauncher = launcher
activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
if (currentLauncher === launcher) {
currentLauncher = null
pendingCallback = null
}
}
})
}
}
@@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.platform
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
/**
* Process-wide registry of the SAF launchers (open + create) used by
* Backup & Restore. Bound by [org.schabi.newpipe.NewPipeComposeActivity] in
* `onCreate`, dropped on `DESTROYED`.
*
* The Compose screen invokes a hook on [org.schabi.newpipe.platform.AppLegacyHooks]
* which (a) builds the SAF intent, (b) stashes a one-shot callback in this
* registry, (c) launches the intent through the right launcher. When the user
* picks/saves a file, the registry invokes the callback.
*/
object ImportExportLauncherRegistry {
@Volatile
internal var openLauncher: ActivityResultLauncher<Intent>? = null
@Volatile
internal var createLauncher: ActivityResultLauncher<Intent>? = null
@Volatile
internal var pendingCallback: ((ActivityResult) -> Unit)? = null
fun bindTo(activity: ComponentActivity) {
val onResult: (ActivityResult) -> Unit = { result ->
val cb = pendingCallback
pendingCallback = null
cb?.invoke(result)
}
val open = activity.registerForActivityResult(StartActivityForResult(), onResult)
val create = activity.registerForActivityResult(StartActivityForResult(), onResult)
openLauncher = open
createLauncher = create
activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
if (openLauncher === open) openLauncher = null
if (createLauncher === create) createLauncher = null
pendingCallback = null
}
})
}
}
@@ -9,7 +9,6 @@ import android.content.Intent
/**
* Build variant dependent (BVD) leak canary API.
* Why is LeakCanary not used directly? Because it can't be assured to be available.
*/
interface DebugSettingsBVDLeakCanaryAPI {
fun getNewLeakDisplayActivityIntent(): Intent
@@ -78,10 +78,6 @@ public class DebugSettingsFragment extends BasePreferenceFragment {
});
}
/**
* Tries to find the {@link DebugSettingsBVDLeakCanaryAPI#IMPL_CLASS} and loads it if available.
* @return An {@link Optional} which is empty if the implementation class couldn't be loaded.
*/
private Optional<DebugSettingsBVDLeakCanaryAPI> getBVDLeakCanary() {
try {
// Try to find the implementation of the LeakCanary API
@@ -70,6 +70,9 @@ import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.settings.SettingsActivity;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import android.content.SharedPreferences;
import androidx.preference.PreferenceManager;
import java.util.List;
import java.util.Optional;
@@ -688,8 +691,19 @@ public final class NavigationHelper {
}
public static void openSettings(final Context context) {
final Intent intent = new Intent(context, SettingsActivity.class);
context.startActivity(intent);
final SharedPreferences prefs =
PreferenceManager.getDefaultSharedPreferences(context);
final boolean useCompose = prefs.getBoolean(
context.getString(R.string.settings_layout_redesign_key), false);
if (useCompose) {
org.schabi.newpipe.platform.ComposeLauncherKt.navigateToCompose(
context,
net.newpipe.app.navigation.Destination.Settings.Home.INSTANCE
);
} else {
final Intent intent = new Intent(context, SettingsActivity.class);
context.startActivity(intent);
}
}
public static void openDownloads(final Activity activity) {
@@ -1513,4 +1513,7 @@
<item>@string/image_quality_medium_key</item>
<item>@string/image_quality_high_key</item>
</string-array>
<!-- Layout redesigh-->
<string name="settings_layout_redesign_key">settings_layout_redesign_key</string>
</resources>
+1 -4
View File
@@ -154,13 +154,11 @@
<string name="settings_category_video_audio_title">Video and audio</string>
<string name="settings_category_history_title">History and cache</string>
<string name="settings_category_appearance_title">Appearance</string>
<string name="settings_category_look_and_feel_title">Look and feel</string>
<string name="settings_category_debug_title">Debug</string>
<string name="settings_category_updates_title">Updates</string>
<string name="settings_category_player_notification_title">Player notification</string>
<string name="settings_category_player_notification_summary">Configure current playing stream notification</string>
<string name="settings_category_backup_restore_title">Backup and restore</string>
<string name="settings_category_content_title">Content</string>
<string name="settings_category_services_title">Services</string>
<string name="settings_category_language_title">Language</string>
<string name="background_player_playing_toast">Playing in background</string>
@@ -909,9 +907,8 @@
<string name="api23_requirement_dialog_blogpost">Blogpost</string>
<string name="settings_category_look_and_feel_title">Look and feel</string>
<string name="settings_category_content_title">Content</string>
<string name="settings_category_services_title">Services</string>
<string name="settings_category_language_title">Language</string>
<string name="navigate_back">Navigate back</string>
<string name="settings_layout_redesign">Enable the Redesigned Settings page</string>
<string name="app_restart_required">Restart NewPipe to apply the new theme</string>
</resources>
+1 -1
View File
@@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx2048M --add-opens jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
org.gradle.jvmargs=-Xmx2048M --add-opens jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
systemProp.file.encoding=utf-8
# https://docs.gradle.org/current/userguide/configuration_cache.html
+1
View File
@@ -163,6 +163,7 @@ puppycrawl-checkstyle = { module = "com.puppycrawl.tools:checkstyle", version.re
reactivex-rxandroid = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid" }
reactivex-rxjava = { module = "io.reactivex.rxjava3:rxjava", version.ref = "rxjava" }
russhwolf-settings-core = { module = "com.russhwolf:multiplatform-settings", version.ref = "settings" }
russhwolf-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "settings" }
russhwolf-settings-test = { module = "com.russhwolf:multiplatform-settings-test", version.ref = "settings" }
squareup-leakcanary-core = { module = "com.squareup.leakcanary:leakcanary-android-core", version.ref = "leakcanary" }
squareup-leakcanary-plumber = { module = "com.squareup.leakcanary:plumber-android", version.ref = "leakcanary" }
+6 -3
View File
@@ -17,12 +17,14 @@ plugins {
// https://stackoverflow.com/a/74771876/8446131
val buildConfigGenerator by tasks.registering(Sync::class) {
val buildConfigPackage = NEWPIPE_APPLICATION_ID_NEW
val isDebug = providers.gradleProperty("buildVariant").orElse("release").map { it == "debug" }
val rawClass = """
package $buildConfigPackage
object BuildConfig {
object BuildConfig {
const val VERSION_NAME = "$NEWPIPE_VERSION_NAME"
const val APP_NAME = "NewPipe"
const val DEBUG = ${isDebug.get()}
}
""".trimIndent()
from(resources.text.fromString(rawClass)) {
@@ -108,10 +110,11 @@ kotlin {
implementation(libs.kotlinx.serialization.json)
implementation(libs.koin.compose.navigation3)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.annotations)
api(libs.koin.compose.viewmodel)
api(libs.koin.annotations)
implementation(libs.russhwolf.settings.core)
implementation(libs.russhwolf.settings.coroutines)
implementation(libs.touchlab.kermit)
}
}
@@ -5,6 +5,7 @@
package net.newpipe.app
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -16,11 +17,21 @@ import kotlinx.serialization.json.Json
import net.newpipe.Constants
import net.newpipe.app.navigation.Destination
import net.newpipe.app.theme.currentService
import org.koin.core.module.Module
import org.koin.dsl.module
/**
* Entry point for compose-related UI components on Android
*/
class ComposeActivity : ComponentActivity() {
open class ComposeActivity : ComponentActivity() {
/** Override to add `:app`-side or other host-specific bindings. */
protected open fun platformModules(): List<Module> {
return listOf(
module { single<Context> { applicationContext } }
)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -28,17 +39,20 @@ class ComposeActivity : ComponentActivity() {
setContent {
App(
// TODO: Change when everything is in compose and this is the primary activity
startDestination = Json.decodeFromString<Destination>(
startDestination = Json.decodeFromString<Destination>(
intent.getStringExtra(Constants.INTENT_SCREEN_KEY)!!
),
onCloseRequest = ::finish
onCloseRequest = ::finish,
platformModules = platformModules()
) {
val view = LocalView.current
val service = currentService()
DisposableEffect(service) {
val windowController = WindowCompat.getInsetsController(window, view)
windowController.isAppearanceLightStatusBars = service.isSchemeColorDensityLight
val windowController =
WindowCompat.getInsetsController(window, view)
windowController.isAppearanceLightStatusBars =
service.isSchemeColorDensityLight
onDispose {
windowController.isAppearanceLightStatusBars = false
}
@@ -7,14 +7,22 @@ package net.newpipe.app.di.settings
import android.content.Context
import androidx.preference.PreferenceManager
import com.russhwolf.settings.ObservableSettings
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings
import org.koin.core.annotation.Singleton
/**
* Settings for Android based on SharedPreferences
* Shared key-value store for Android, backed by the default
* [PreferenceManager] so Compose Multiplatform code and the legacy Views
* fragments share the same `SharedPreferences`.
*
* Registered under both [ObservableSettings] (for flow-based callers like
* [net.newpipe.app.viewmodel.settings.BooleanPreference]) and [Settings]
* (for the original theme-layer callers).
*/
@Singleton
fun provideSettings(context: Context): Settings = SharedPreferencesSettings(
PreferenceManager.getDefaultSharedPreferences(context)
)
@Singleton(binds = [ObservableSettings::class, Settings::class])
fun provideSettings(context: Context): ObservableSettings =
SharedPreferencesSettings(
PreferenceManager.getDefaultSharedPreferences(context)
)
@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import org.koin.core.annotation.Singleton
@Singleton(binds = [BackupRestoreActions::class])
class AndroidBackupRestoreActions(
private val legacyHooks: AndroidLegacyHooks
) : BackupRestoreActions {
override fun importDatabase() = legacyHooks.importDatabase()
override fun exportDatabase() = legacyHooks.exportDatabase()
override fun resetAllSettings() = legacyHooks.resetAllSettings()
override fun importSubscriptions() = legacyHooks.importSubscriptions()
override fun exportSubscriptions() = legacyHooks.exportSubscriptions()
}
@@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import org.koin.core.annotation.Singleton
@Singleton(binds = [ContentActions::class])
class AndroidContentActions(
private val legacyHooks: AndroidLegacyHooks
) : ContentActions {
override fun openMainPageTabsChooser() = legacyHooks.openMainPageTabsChooser()
override fun openPeertubeInstanceList() = legacyHooks.openPeertubeInstanceList()
override fun onAppLanguageChanged() = legacyHooks.onAppLanguageChanged()
}
@@ -1,2 +1,45 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import android.app.Application
import android.content.Intent
import org.koin.core.annotation.Singleton
/**
* Android implementation of [DebugActions].
*
* Receives [AndroidLegacyHooks] through Koin. The host activity
* (`NewPipeComposeActivity`) overrides the default placeholder binding from
* [AndroidLegacyHooksModule] with the real implementation (`AppLegacyHooks`)
* via its `platformModules`.
*/
@Singleton(binds = [DebugActions::class])
class AndroidDebugActions(
private val application: Application,
private val legacyHooks: AndroidLegacyHooks
) : DebugActions {
override fun crashTheApp() {
throw RuntimeException("Dummy")
}
override fun showErrorSnackbar() = legacyHooks.showUiErrorSnackbar()
override fun createErrorNotification() = legacyHooks.createErrorNotification()
override fun checkNewStreams() = legacyHooks.runNotificationWorkerNow()
override fun showMemoryLeaks() {
legacyHooks.newLeakDisplayActivityIntent()?.let { intent ->
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
application.startActivity(intent)
}
}
override fun isLeakCanaryAvailable(): Boolean =
legacyHooks.newLeakDisplayActivityIntent() != null
}
@@ -1,2 +1,17 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import org.koin.core.annotation.Singleton
@Singleton(binds = [DownloadActions::class])
class AndroidDownloadActions(
private val legacyHooks: AndroidLegacyHooks
) : DownloadActions {
override fun pickDirectory(onPicked: (String) -> Unit) =
legacyHooks.requestDirectoryPicker(onPicked)
}
@@ -1,2 +1,23 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import org.koin.core.annotation.Singleton
@Singleton(binds = [HistoryActions::class])
class AndroidHistoryActions(
private val legacyHooks: AndroidLegacyHooks
) : HistoryActions {
override fun wipeMetadataCache() = legacyHooks.wipeMetadataCache()
override fun deleteWatchHistory() = legacyHooks.deleteWatchHistory()
override fun deletePlaybackStates() = legacyHooks.deletePlaybackStates()
override fun deleteSearchHistory() = legacyHooks.deleteSearchHistory()
override fun clearRecaptchaCookies() =
legacyHooks.clearRecaptchaCookies()
override fun hasRecaptchaCookies(): Boolean =
legacyHooks.hasRecaptchaCookies()
}
@@ -1,2 +1,55 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import android.content.Intent
/**
* Temporary bridge for Android-only operations that still live in the legacy `:app`
* module (ErrorUtil, NotificationWorker, the LeakCanary reflection bootstrap).
*
* Delete this file once those modules are migrated to `:shared/androidMain` and
inline their calls into [AndroidDebugActions].
* */
interface AndroidLegacyHooks {
// DebugActions / ErrorUtil / LeakCanary
fun showUiErrorSnackbar()
fun createErrorNotification()
fun runNotificationWorkerNow()
fun newLeakDisplayActivityIntent(): Intent?
// DownloadActions
fun requestDirectoryPicker(onPicked: (uri: String) -> Unit)
// UpdateActions
fun runManualUpdateCheck()
// HistoryActions
fun wipeMetadataCache()
fun deleteWatchHistory()
fun deletePlaybackStates()
fun deleteSearchHistory()
fun clearRecaptchaCookies()
fun hasRecaptchaCookies(): Boolean
// LookFeelActions
fun applyTheme(newThemeKey: String)
fun applyNightTheme(newNightThemeKey: String)
fun showSelectNightThemeToast()
fun openCaptionSettings()
// BackupRestoreActions
fun importDatabase()
fun exportDatabase()
fun resetAllSettings()
fun importSubscriptions()
fun exportSubscriptions()
// ContentActions
fun openMainPageTabsChooser()
fun openPeertubeInstanceList()
fun onAppLanguageChanged()
}
@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import org.koin.core.annotation.Singleton
@Singleton(binds = [LookFeelActions::class])
class AndroidLookFeelActions(
private val legacyHooks: AndroidLegacyHooks
) : LookFeelActions {
override fun applyTheme(newThemeKey: String) =
legacyHooks.applyTheme(newThemeKey)
override fun applyNightTheme(newNightThemeKey: String) =
legacyHooks.applyNightTheme(newNightThemeKey)
override fun showSelectNightThemeToast() =
legacyHooks.showSelectNightThemeToast()
override fun openCaptionSettings() =
legacyHooks.openCaptionSettings()
}
@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import org.koin.core.annotation.Singleton
@Singleton(binds = [UpdateActions::class])
class AndroidUpdateActions(
private val legacyHooks: AndroidLegacyHooks
) : UpdateActions {
override fun runManualCheck() = legacyHooks.runManualUpdateCheck()
}
@@ -1,4 +1,57 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
class DefaultAndroidLegacyHooks {
}
import android.content.Intent
import org.koin.core.annotation.Singleton
/**
* Placeholder [AndroidLegacyHooks] binding that satisfies Koin's compile-time
* graph verification.
*
* At runtime the host activity (`NewPipeComposeActivity` in `:app`) registers
* the real [AndroidLegacyHooks] implementation (`AppLegacyHooks`) through
* `platformModules`. Koin's default override behavior means the host's
* registration replaces this stub — so every method below should never be
* called in a properly configured run.
*
* Delete this class once the legacy `:app` modules (`ErrorUtil`,
* `NotificationWorker`, `HistoryRecordManager`, `NewVersionWorker`, etc.)
* migrate into `:shared/androidMain` and a real implementation can live here.
*/
@Singleton(binds = [AndroidLegacyHooks::class])
internal class DefaultAndroidLegacyHooks : AndroidLegacyHooks {
override fun showUiErrorSnackbar() = unsupported("showUiErrorSnackbar")
override fun createErrorNotification() = unsupported("createErrorNotification")
override fun runNotificationWorkerNow() = unsupported("runNotificationWorkerNow")
override fun newLeakDisplayActivityIntent(): Intent? = null
override fun requestDirectoryPicker(onPicked: (String) -> Unit) = unsupported("requestDirectoryPicker")
override fun runManualUpdateCheck() = unsupported("runManualUpdateCheck")
override fun wipeMetadataCache() = unsupported("wipeMetadataCache")
override fun deleteWatchHistory() = unsupported("deleteWatchHistory")
override fun deletePlaybackStates() = unsupported("deletePlaybackStates")
override fun deleteSearchHistory() = unsupported("deleteSearchHistory")
override fun clearRecaptchaCookies() = unsupported("clearRecaptchaCookies")
override fun hasRecaptchaCookies(): Boolean = false
override fun applyTheme(newThemeKey: String) = unsupported("applyTheme")
override fun applyNightTheme(newNightThemeKey: String) = unsupported("applyNightTheme")
override fun showSelectNightThemeToast() = unsupported("showSelectNightThemeToast")
override fun openCaptionSettings() = unsupported("openCaptionSettings")
override fun importDatabase() = unsupported("importDatabase")
override fun exportDatabase() = unsupported("exportDatabase")
override fun resetAllSettings() = unsupported("resetAllSettings")
override fun importSubscriptions() = unsupported("importSubscriptions")
override fun exportSubscriptions() = unsupported("exportSubscriptions")
override fun openMainPageTabsChooser() = unsupported("openMainPageTabsChooser")
override fun openPeertubeInstanceList() = unsupported("openPeertubeInstanceList")
override fun onAppLanguageChanged() = unsupported("onAppLanguageChanged")
private fun unsupported(method: String): Nothing = error(
"AndroidLegacyHooks.$method called without a host binding — " +
"NewPipeComposeActivity.platformModules() must register AppLegacyHooks."
)
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="@color/defaultIconTint"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M480,840Q405,840 339.5,811.5Q274,783 225.5,734.5Q177,686 148.5,620.5Q120,555 120,480Q120,405 148.5,339.5Q177,274 225.5,225.5Q274,177 339.5,148.5Q405,120 480,120Q562,120 635.5,155Q709,190 760,254L760,160L840,160L840,400L600,400L600,320L710,320Q669,264 609,232Q549,200 480,200Q363,200 281.5,281.5Q200,363 200,480Q200,597 281.5,678.5Q363,760 480,760Q585,760 663.5,692Q742,624 756,520L838,520Q823,657 720.5,748.5Q618,840 480,840ZM592,648L440,496L440,280L520,280L520,464L648,592L592,648Z"/>
</vector>
@@ -34,4 +34,288 @@
<string name="open_in_browser">Open in browser</string>
<string name="done">Done</string>
<string name="not_available">Not available</string>
<!-- Settings -->
<string name="settings">Settings</string>
<string name="settings_category_player_title">Player</string>
<string name="settings_category_player_behavior_title">Behavior</string>
<string name="settings_category_downloads_title">Downloads</string>
<string name="settings_category_look_and_feel_title">Look and feel</string>
<string name="settings_category_history_title">History &amp; cache</string>
<string name="settings_category_content_title">Content</string>
<string name="settings_category_feed_title">Feed</string>
<string name="settings_category_services_title">Services</string>
<string name="settings_category_language_title">Language</string>
<string name="settings_category_backup_restore_title">Backup &amp; restore</string>
<string name="settings_category_updates_title">Updates</string>
<string name="settings_category_debug_title">Debug</string>
<string name="settings_layout_redesign">Enable the Redesigned Settings page</string>
<string name="leakcanary">LeakCanary</string>
<string name="enable_leak_canary_summary">Allow heap dumping</string>
<string name="leak_canary_not_available">LeakCanary is not available in this build</string>
<string name="show_memory_leaks">Show memory leaks</string>
<string name="enable_disposed_exceptions_title">Enable disposed exceptions</string>
<string name="enable_disposed_exceptions_summary">Throw on RxJava onErrorNotImplemented after disposal</string>
<string name="show_original_time_ago_title">Show original time-ago</string>
<string name="show_original_time_ago_summary">Disable smoothing of upload dates</string>
<string name="show_crash_the_player_title">Show "crash the player" option</string>
<string name="show_crash_the_player_summary">Adds an option to the player menu to forcibly crash it</string>
<string name="check_new_streams">Check new streams now</string>
<string name="crash_the_app">Crash the app</string>
<string name="show_error_snackbar">Show error snackbar</string>
<string name="create_error_notification">Create error notification</string>
<string name="downloads_storage_ask_title">Ask where to download</string>
<string name="downloads_storage_ask_summary">You will be asked where to save each download.\nEnable the system folder picker (SAF) if you want to download to an external SD card</string>
<string name="downloads_storage_use_saf_title">Use system folder picker (SAF)</string>
<string name="downloads_storage_use_saf_summary">The \'Storage Access Framework\' allows downloads to an external SD card</string>
<string name="download_path_title">Video download folder</string>
<string name="download_path_summary">Downloaded video files are stored here</string>
<string name="download_path_audio_title">Audio download folder</string>
<string name="download_path_audio_summary">Downloaded audio files are stored here</string>
<!-- HistorySettingsScreen -->
<string name="enable_watch_history_title">Watch history</string>
<string name="enable_watch_history_summary">Keep track of watched videos</string>
<string name="enable_playback_resume_title">Resume playback</string>
<string name="enable_playback_resume_summary">Restore last playback position</string>
<string name="enable_playback_state_lists_title">Positions in lists</string>
<string name="enable_playback_state_lists_summary">Show playback position indicators in lists</string>
<string name="enable_search_history_title">Search history</string>
<string name="enable_search_history_summary">Store search queries locally</string>
<string name="settings_category_clear_data_title">Clear data</string>
<string name="metadata_cache_wipe_title">Wipe cached metadata</string>
<string name="metadata_cache_wipe_summary">Remove all cached webpage data</string>
<string name="clear_views_history_title">Clear watch history</string>
<string name="clear_views_history_summary">Deletes the history of played streams and the playback positions</string>
<string name="clear_playback_states_title">Delete playback positions</string>
<string name="clear_playback_states_summary">Deletes all playback positions</string>
<string name="clear_search_history_title">Clear search history</string>
<string name="clear_search_history_summary">Deletes history of search keywords</string>
<string name="clear_cookie_title">Clear reCAPTCHA cookies</string>
<string name="clear_cookie_summary">Clear cookies that NewPipe stores when you solve a reCAPTCHA</string>
<!-- Generic action labels -->
<string name="cancel">Cancel</string>
<string name="delete">Delete</string>
<!-- PlayerSettingsScreen -->
<string name="settings_category_video_audio_title">Video &amp; Audio</string>
<string name="default_resolution_title">Default resolution</string>
<string name="default_popup_resolution_title">Default popup resolution</string>
<string name="limit_mobile_data_usage_title">Mobile data limit</string>
<string name="show_higher_resolutions_title">Show higher resolutions</string>
<string name="show_higher_resolutions_summary">Only some devices can play 2K/4K videos</string>
<string name="default_video_format_title">Default video format</string>
<string name="default_audio_format_title">Default audio format</string>
<string name="prefer_original_audio_title">Prefer original audio</string>
<string name="prefer_original_audio_summary">Pick the audio language marked as original by the uploader, when available</string>
<string name="prefer_descriptive_audio_title">Prefer descriptive audio</string>
<string name="prefer_descriptive_audio_summary">Pick the audio track that describes what is happening on screen, when available</string>
<string name="settings_category_exoplayer_title">ExoPlayer settings</string>
<string name="settings_category_exoplayer_summary">Configure ExoPlayer (the underlying video player)</string>
<string name="use_external_video_player_title">Use external video player</string>
<string name="use_external_video_player_summary">Some formats are unavailable when this is on</string>
<string name="use_external_audio_player_title">Use external audio player</string>
<string name="show_play_with_kodi_title">Show \"Play with Kodi\" option</string>
<string name="show_play_with_kodi_summary">Show an option to play a video via Kodi</string>
<string name="seekbar_preview_thumbnail_title">Seekbar preview thumbnails</string>
<!-- Resolution labels -->
<string name="best_resolution">Best resolution</string>
<string name="resolution_2160p">2160p</string>
<string name="resolution_1440p">1440p</string>
<string name="resolution_1080p60">1080p60</string>
<string name="resolution_1080p">1080p</string>
<string name="resolution_720p60">720p60</string>
<string name="resolution_720p">720p</string>
<string name="resolution_480p">480p</string>
<string name="resolution_360p">360p</string>
<string name="resolution_240p">240p</string>
<string name="resolution_144p">144p</string>
<!-- Mobile data limit labels -->
<string name="limit_data_usage_none">No limit</string>
<string name="limit_data_usage_wifi_only">Restrict to Wi-Fi</string>
<!-- Format labels -->
<string name="video_format_mp4">MP4</string>
<string name="video_format_webm">WebM</string>
<string name="video_format_3gp">3GP</string>
<string name="audio_format_m4a">M4A</string>
<string name="audio_format_webm">WebM (Opus)</string>
<string name="audio_format_ogg">OGG (Vorbis)</string>
<!-- Seekbar preview thumbnail labels -->
<string name="seekbar_preview_thumbnail_high">High quality</string>
<string name="seekbar_preview_thumbnail_low">Low quality</string>
<string name="seekbar_preview_thumbnail_off">Off</string>
<!-- ExoPlayerSettingsScreen -->
<string name="progressive_load_interval_title">Playback load interval size</string>
<string name="progressive_load_interval_summary">Change the load interval size on progressive contents. A lower value may speed up their initial loading</string>
<string name="progressive_load_interval_1_kib">1 KiB</string>
<string name="progressive_load_interval_16_kib">16 KiB</string>
<string name="progressive_load_interval_64_kib">64 KiB</string>
<string name="progressive_load_interval_256_kib">256 KiB</string>
<string name="progressive_load_interval_exoplayer_default">ExoPlayer default</string>
<string name="use_exoplayer_decoder_fallback_title">Use ExoPlayer\'s decoder fallback feature</string>
<string name="use_exoplayer_decoder_fallback_summary">Enable this option if you have decoder initialization issues, which falls back to lower-priority decoders if primary decoders initialization fail. This may result in poor playback performance than when using primary decoders</string>
<string name="disable_media_tunneling_title">Disable media tunneling</string>
<string name="disable_media_tunneling_summary">Disable media tunneling if you experience a black screen or stuttering on video playback.</string>
<string name="always_use_exoplayer_set_output_surface_workaround_title">Always use ExoPlayer\'s video output surface setting workaround</string>
<string name="always_use_exoplayer_set_output_surface_workaround_summary">This workaround releases and re-instantiates video codecs when a surface change occurs, instead of setting the surface to the codec directly. Already used by ExoPlayer on some devices with this issue, this setting has only an effect on Android 6 and higher\n\nEnabling this option may prevent playback errors when switching the current video player or switching to fullscreen</string>
<!-- Generic shared labels (reused by ConfirmDialog & legacy parity) -->
<string name="ok">OK</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<!-- UpdatesSettingsScreen -->
<string name="updates_setting_title">Updates</string>
<string name="updates_setting_description">Show a notification to prompt app update when a new version is available</string>
<string name="check_for_updates">Check for updates</string>
<string name="manual_update_description">Manually check for new versions</string>
<!-- LookFeel / AppearanceSettingsScreen -->
<string name="settings_category_appearance_title">Appearance</string>
<string name="theme_title">Theme</string>
<string name="theme_light">Light theme</string>
<string name="theme_dark">Dark theme</string>
<string name="theme_black">Black theme</string>
<string name="theme_auto">Automatic (device theme)</string>
<string name="night_theme_title">Night theme</string>
<string name="night_theme_summary">Select your favorite night theme</string>
<string name="night_theme_available">This option is only available if %s is selected for Theme</string>
<string name="select_night_theme_toast">You can select your favorite night theme below</string>
<string name="show_hold_to_append_title">Show \"Hold to enqueue\" tip</string>
<string name="show_hold_to_append_summary">Show tip when pressing the background or the popup button in video details</string>
<string name="tablet_mode_title">Tablet mode</string>
<string name="tablet_mode_auto">Automatic</string>
<string name="tablet_mode_on">On</string>
<string name="tablet_mode_off">Off</string>
<string name="list_view_mode_title">List view mode</string>
<string name="list_view_mode_auto">Automatic</string>
<string name="list_view_mode_list">List</string>
<string name="list_view_mode_grid">Grid</string>
<string name="list_view_mode_card">Card</string>
<string name="caption_setting_title">Captions</string>
<string name="caption_setting_description">Modify player caption text scale and background styles. Requires app restart to take effect</string>
<string name="main_tabs_position_title">Main tabs position</string>
<string name="main_tabs_position_summary">Move main tab selector to the bottom</string>
<!-- BackupRestoreSettingsScreen -->
<string name="import_data_title">Import database</string>
<string name="import_data_summary">Overrides your current history, subscriptions, playlists and (optionally) settings</string>
<string name="export_data_title">Export database</string>
<string name="export_data_summary">Export history, subscriptions, playlists and settings</string>
<string name="reset_settings_title">Reset settings</string>
<string name="reset_settings_summary">Reset all settings to their default values</string>
<string name="reset_all_settings">Resetting all settings will discard all of your preferred settings and restart the app.\n\nAre you sure you want to proceed?</string>
<string name="import_subscriptions_title">Import subscriptions</string>
<string name="import_subscriptions_summary">Import subscriptions from a previous .json export</string>
<string name="export_subscriptions_title">Export subscriptions</string>
<string name="export_subscriptions_summary">Export your subscriptions to a .json file</string>
<string name="override_current_data">This will override your current setup.</string>
<string name="import_settings">Do you want to also import settings?</string>
<string name="export_complete_toast">Exported</string>
<string name="no_valid_zip_file">No valid ZIP file</string>
<string name="import_settings_vulnerable_format">The settings being imported use a vulnerable format deprecated since NewPipe 0.27.0. Make sure the export is from a trusted source.</string>
<!-- ContentSettingsScreen -->
<string name="app_language_title">App language</string>
<string name="content">Content</string>
<string name="content_language_title">Default content language</string>
<string name="default_content_country_title">Default content country</string>
<string name="main_page_content">Content of main page</string>
<string name="main_page_content_summary">What tabs are shown on the main page</string>
<string name="show_channel_tabs">Channel tabs</string>
<string name="show_channel_tabs_summary">What tabs are shown on the channel pages</string>
<string name="peertube_instance_url_title">PeerTube instances</string>
<string name="peertube_instance_url_summary">Select your favorite PeerTube instances</string>
<string name="show_age_restricted_content_title">Show age restricted content</string>
<string name="show_age_restricted_content_summary">Show content possibly unsuitable for children because it has an age limit (like 18+)</string>
<string name="youtube_restricted_mode_enabled_title">Turn on YouTube\'s \"Restricted Mode\"</string>
<string name="youtube_restricted_mode_enabled_summary">YouTube provides a \"Restricted Mode\" which hides potentially mature content</string>
<string name="show_search_suggestions_title">Search suggestions</string>
<string name="show_search_suggestions_summary">Choose the suggestions to show when searching</string>
<string name="image_quality_title">Image quality</string>
<string name="image_quality_summary">Choose the quality of images and whether to load images at all, to reduce data and memory usage</string>
<string name="image_quality_low">Low</string>
<string name="image_quality_medium">Medium</string>
<string name="image_quality_high">High</string>
<string name="show_comments_title">Show comments</string>
<string name="show_comments_summary">Turn off to hide comments</string>
<string name="show_next_and_similar_title">Show \'Next\' and \'Similar\' videos</string>
<string name="show_description_title">Show description</string>
<string name="show_description_summary">Turn off to hide video description and additional information</string>
<string name="show_meta_info_title">Show meta info</string>
<string name="show_meta_info_summary">Turn off to hide meta info boxes with additional information</string>
<!-- Feed sub-category -->
<string name="feed_update_threshold_title">Feed update threshold</string>
<string name="feed_update_threshold_summary">Time after last update before a subscription is considered outdated</string>
<string name="feed_use_dedicated_fetch_method_title">Fetch from dedicated feed when available</string>
<string name="feed_use_dedicated_fetch_method_summary">Available in some services, usually much faster but may return limited info</string>
<string name="feed_fetch_channel_tabs">Fetch channel tabs</string>
<string name="feed_fetch_channel_tabs_summary">Tabs to fetch when updating the feed. No effect if fast mode is used.</string>
<!-- Feed threshold value labels -->
<string name="threshold_immediate">Immediately</string>
<string name="threshold_5_min">After 5 minutes</string>
<string name="threshold_15_min">After 15 minutes</string>
<string name="threshold_1_hour">After 1 hour</string>
<string name="threshold_6_hours">After 6 hours</string>
<string name="threshold_12_hours">After 12 hours</string>
<string name="threshold_1_day">After 1 day</string>
<!-- Channel-tab labels -->
<string name="channel_tab_videos">Videos</string>
<string name="channel_tab_tracks">Tracks</string>
<string name="channel_tab_shorts">Shorts</string>
<string name="channel_tab_livestreams">Livestreams</string>
<string name="channel_tab_channels">Channels</string>
<string name="channel_tab_playlists">Playlists</string>
<string name="channel_tab_albums">Albums</string>
<string name="channel_tab_likes">Likes</string>
<string name="channel_tab_about">About</string>
<!-- Search-suggestion labels -->
<string name="local_search_suggestions">Local</string>
<string name="remote_search_suggestions">Remote</string>
<!-- Localization labels -->
<string name="language_system">System default</string>
<string name="language_en">English</string>
<string name="language_es">Spanish</string>
<string name="language_fr">French</string>
<string name="language_de">German</string>
<string name="language_pt">Portuguese</string>
<string name="language_ru">Russian</string>
<string name="language_zh">Chinese</string>
<string name="language_ja">Japanese</string>
<string name="language_ko">Korean</string>
<string name="language_it">Italian</string>
<string name="language_nl">Dutch</string>
<string name="language_pl">Polish</string>
<string name="language_tr">Turkish</string>
<string name="language_ar">Arabic</string>
<string name="language_hi">Hindi</string>
<string name="country_system">System default</string>
<string name="country_us">United States</string>
<string name="country_gb">United Kingdom</string>
<string name="country_de">Germany</string>
<string name="country_fr">France</string>
<string name="country_es">Spain</string>
<string name="country_it">Italy</string>
<string name="country_br">Brazil</string>
<string name="country_jp">Japan</string>
<string name="country_kr">South Korea</string>
<string name="country_in">India</string>
<string name="country_ru">Russia</string>
<string name="country_cn">China</string>
<string name="country_au">Australia</string>
<string name="country_ca">Canada</string>
<string name="country_mx">Mexico</string>
</resources>
@@ -12,24 +12,31 @@ import net.newpipe.app.navigation.NavDisplay
import net.newpipe.app.navigation.navModule
import net.newpipe.app.theme.AppTheme
import org.koin.compose.KoinApplication
import org.koin.core.module.Module
import org.koin.plugin.module.dsl.koinConfiguration
/**
* Entry point for the multiplatform compose application
* @param startDestination Starting destination for the app; defaults to about
* @param onCloseRequest Callback to close the app
* @param withKoin Additional logic to execute after initialising Koin and setting content
* Entry point for the multiplatform compose application.
*
* @param startDestination Starting destination for the app; defaults to About.
* @param onCloseRequest Callback to close the app.
* @param platformModules Extra Koin modules supplied by the host platform.
* Android passes a module that registers the running Application/Context;
* iOS / desktop pass an empty list.
* @param withKoin Extra composable content rendered inside the Koin context.
*/
@Composable
fun App(
startDestination: Destination = Destination.About,
onCloseRequest: () -> Unit,
platformModules: List<Module> = emptyList(),
withKoin: @Composable () -> Unit = {}
) {
KoinApplication(
configuration = koinConfiguration<KoinApp>(
appDeclaration = {
modules(navModule())
modules(platformModules)
}
)
) {
@@ -1,2 +1,79 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.composable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.preview.ThemePreviewProvider
/**
* Generic yes/no confirmation dialog.
*
* @param title Dialog title.
* @param message Body text shown above the buttons.
* @param confirmLabel Label for the confirm button (defaults to "OK"-style).
* @param dismissLabel Label for the dismiss button.
* @param onConfirm Called when the user accepts.
* @param onDismiss Called when the user dismisses or taps outside.
*/
@Composable
fun ConfirmDialog(
title: String,
message: String,
confirmLabel: String,
dismissLabel: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title) },
text = { Text(text = message) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = confirmLabel, color =
MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(text = dismissLabel) }
}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun ConfirmDialogPreview() {
ConfirmDialog(
title = "Clear watch history",
message = "Deletes the history of played streams and the playback positions.", confirmLabel = "Delete",
dismissLabel = "Cancel",
onConfirm = {},
onDismiss = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun ConfirmDialogResetSettingsPreview() {
ConfirmDialog(
title = "Reset settings",
message = "Resetting all settings will discard all of your preferred settings and " + "restart the app.\n\nAre you sure you want to proceed?",
confirmLabel = "OK",
dismissLabel = "Cancel",
onConfirm = {},
onDismiss = {}
)
}
@@ -1,2 +1,161 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.composable
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.selection.selectable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.theme.spaceSmall
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.cancel
import org.jetbrains.compose.resources.stringResource
/**
* One entry in a [ListPreference].
* @param value Persisted to [com.russhwolf.settings.ObservableSettings].
* @param label Display label shown in the dialog and as the row summary.
*/
data class ListPreferenceOption(
val value: String,
val label: String
)
/**
* Row that opens a radio-button dialog for picking a single value from
* [options].
*
* @param summary Optional static summary. When `null`, the row's summary
* shows the label of the currently selected option (legacy parity with
* `useSimpleSummaryProvider="true"`). Pass a non-null string to mirror the
* legacy `android:summary="…"` static-description behavior.
*/
@Composable
fun ListPreference(
title: String,
options: List<ListPreferenceOption>,
selectedValue: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
enabled: Boolean = true
) {
var dialogOpen by rememberSaveable { mutableStateOf(false) }
val effectiveSummary = summary ?: options.firstOrNull { it.value == selectedValue }?.label
TextPreference(
title = title,
summary = effectiveSummary,
onClick = { dialogOpen = true },
modifier = modifier,
enabled = enabled
)
if (dialogOpen) {
AlertDialog(
onDismissRequest = { dialogOpen = false },
title = { Text(text = title) },
text = {
Column {
options.forEach { option ->
val isSelected = option.value == selectedValue
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = {
onValueChange(option.value)
dialogOpen = false
}
)
.padding(vertical = spaceSmall),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = isSelected,
onClick = null
)
Text(
text = option.label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = spaceSmall)
)
}
}
}
},
confirmButton = {
TextButton(onClick = { dialogOpen = false }) {
Text(text = stringResource(Res.string.cancel))
}
}
)
}
}
private val previewOptions = listOf(
ListPreferenceOption("best_resolution", "Best resolution"),
ListPreferenceOption("1080p", "1080p"),
ListPreferenceOption("720p", "720p"),
ListPreferenceOption("480p", "480p")
)
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun ListPreferenceSelectedLabelSummaryPreview() {
ListPreference(
title = "Default resolution",
options = previewOptions,
selectedValue = "720p",
onValueChange = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun ListPreferenceStaticSummaryPreview() {
ListPreference(
title = "Image quality",
summary = "Choose the quality of images and whether to load images at all", options = previewOptions,
selectedValue = "best_resolution",
onValueChange = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun ListPreferenceDisabledPreview() {
ListPreference(
title = "Night theme",
options = previewOptions,
selectedValue = "1080p",
onValueChange = {},
enabled = false
)
}
@@ -0,0 +1,145 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.composable
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.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.theme.spaceSmall
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.cancel
import newpipe.shared.generated.resources.ok
import org.jetbrains.compose.resources.stringResource
/**
* One option in a [MultiSelectListPreference].
**/
data class MultiSelectListPreferenceOption(
val value: String,
val label: String
)
/**
* Row that opens a checkbox dialog letting the user pick zero or more values
* from [options]. Pending selection is held locally so Cancel reverts.
*/
@Composable
fun MultiSelectListPreference(
title: String,
options: List<MultiSelectListPreferenceOption>,
selectedValues: Set<String>,
onValuesChange: (Set<String>) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
enabled: Boolean = true
) {
var dialogOpen by rememberSaveable { mutableStateOf(false) }
val effectiveSummary = summary ?: options
.filter { it.value in selectedValues }
.joinToString(", ") { it.label }
.ifEmpty { null }
TextPreference(
title = title,
summary = effectiveSummary,
onClick = { dialogOpen = true },
modifier = modifier,
enabled = enabled
)
if (dialogOpen) {
var pending by remember { mutableStateOf(selectedValues) }
LaunchedEffect(selectedValues) { pending = selectedValues }
AlertDialog(
onDismissRequest = { dialogOpen = false },
title = { Text(text = title) },
text = {
Column(modifier =
Modifier.verticalScroll(rememberScrollState())) {
options.forEach { option ->
val checked = option.value in pending
Row(
modifier = Modifier
.fillMaxWidth()
.toggleable(
value = checked,
onValueChange = { isChecked ->
pending = if (isChecked) pending +
option.value
else pending - option.value
}
)
.padding(vertical = spaceSmall),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = checked, onCheckedChange = null)
Text(
text = option.label,
style =
MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start =
spaceSmall)
)
}
}
}
},
confirmButton = {
TextButton(onClick = {
onValuesChange(pending)
dialogOpen = false
}) {
Text(text = stringResource(Res.string.ok))
}
},
dismissButton = {
TextButton(onClick = { dialogOpen = false }) {
Text(text = stringResource(Res.string.cancel))
}
}
)
}
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun MultiSelectListPreferencePreview() {
val options = listOf(
MultiSelectListPreferenceOption("a", "Apple"),
MultiSelectListPreferenceOption("b", "Banana"),
MultiSelectListPreferenceOption("c", "Cherry")
)
MultiSelectListPreference(
title = "Pick fruits",
options = options,
selectedValues = setOf("a", "c"),
onValuesChange = {}
)
}
@@ -1,2 +1,47 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.composable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.theme.spaceLarge
import net.newpipe.app.theme.spaceSmall
/**
* Section header used to group preference rows.
*
* Maps to `<PreferenceCategory android:title="…">` in the legacy XML
* preference system.
*/
@Composable
fun PreferenceCategoryHeader(
title: String,
modifier: Modifier = Modifier
) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = spaceLarge, vertical = spaceSmall)
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun PreferenceCategoryHeaderPreview() {
PreferenceCategoryHeader(title = "Clear data")
}
@@ -1,2 +1,78 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.composable
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.preview.ThemePreviewProvider
private const val DISABLED_ALPHA = 0.38f
@Composable
internal fun PreferenceText(
title: String,
summary: String?,
enabled: Boolean = true
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Start,
color = if (enabled) Color.Unspecified else MaterialTheme.colorScheme.onSurface.copy(alpha = DISABLED_ALPHA)
)
summary?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
color = if (enabled) Color.Unspecified else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = DISABLED_ALPHA)
)
}
}
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun PreferenceTextEnabledPreview() {
PreferenceText(
title = "Default resolution",
summary = "Best resolution",
enabled = true
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun PreferenceTextDisabledPreview() {
PreferenceText(
title = "Resume playback",
summary = "Restore last playback position",
enabled = false
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun PreferenceTextNoSummaryPreview() {
PreferenceText(
title = "Crash the app",
summary = null,
enabled = true
)
}
@@ -1,2 +1,102 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.composable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.theme.spaceSmall
import net.newpipe.app.theme.spaceXSmall
@Composable
fun SwitchPreference(
title: String,
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
enabled: Boolean = true
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(spaceSmall)
) {
Column(modifier = Modifier.weight(1f)) {
PreferenceText(title = title, summary = summary, enabled = enabled)
}
Spacer(modifier = Modifier.width(spaceXSmall))
Switch(
checked = isChecked,
onCheckedChange = onCheckedChange,
enabled = enabled
)
}
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun SwitchPreferenceCheckedPreview() {
SwitchPreference(
title = "Watch history",
summary = "Keep track of watched videos",
isChecked = true,
onCheckedChange = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun SwitchPreferenceUncheckedPreview() {
SwitchPreference(
title = "Use external video player",
summary = "Some formats are unavailable when this is on",
isChecked = false,
onCheckedChange = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun SwitchPreferenceNoSummaryPreview() {
SwitchPreference(
title = "Use external audio player",
isChecked = false,
onCheckedChange = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun SwitchPreferenceDisabledPreview() {
SwitchPreference(
title = "Resume playback",
summary = "Restore last playback position",
isChecked = true,
onCheckedChange = {},
enabled = false
)
}
@@ -1,2 +1,117 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.composable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.theme.preferenceMinHeight
import net.newpipe.app.theme.spaceSmall
import net.newpipe.app.theme.spaceXSmall
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.ic_settings
import org.jetbrains.compose.resources.painterResource
private const val DISABLED_ALPHA = 0.38f
@Composable
fun TextPreference(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: Painter? = null,
summary: String? = null,
enabled: Boolean = true
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = modifier
.fillMaxWidth()
.padding(spaceSmall)
.defaultMinSize(minHeight = preferenceMinHeight)
.clickable(enabled = enabled) { onClick() }
) {
icon?.let {
Icon(
painter = it,
contentDescription = null,
tint = if (enabled) Color.Unspecified else MaterialTheme.colorScheme.onSurface.copy(alpha = DISABLED_ALPHA)
)
Spacer(modifier = Modifier.width(spaceXSmall))
}
Column {
PreferenceText(title = title, summary = summary, enabled = enabled)
}
}
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun TextPreferenceWithIconPreview() {
TextPreference(
title = "Player",
summary = "Resolution, format, external player…",
icon = painterResource(Res.drawable.ic_settings),
onClick = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun TextPreferenceNoIconPreview() {
TextPreference(
title = "Reset settings",
summary = "Reset all settings to their default values",
onClick = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun TextPreferenceTitleOnlyPreview() {
TextPreference(
title = "Crash the app",
onClick = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun TextPreferenceDisabledPreview() {
TextPreference(
title = "Show memory leaks",
summary = "LeakCanary is not available in this build",
onClick = {},
enabled = false
)
}
@@ -7,7 +7,6 @@ package net.newpipe.app.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
import net.newpipe.app.model.License
/**
* Destinations for navigation in compose
@@ -17,4 +16,51 @@ sealed interface Destination : NavKey {
@Serializable
data object About : Destination
@Serializable
sealed interface Settings : Destination {
@Serializable
data object Home : Settings
@Serializable
data object Player : Settings
@Serializable
data object Behaviour : Settings
@Serializable
data object Download : Settings
@Serializable
data object LookFeel : Settings
@Serializable
data object HistoryCache : Settings
@Serializable
data object Content : Settings
@Serializable
data object Feed : Settings
@Serializable
data object Services : Settings
@Serializable
data object Language : Settings
@Serializable
data object BackupRestore : Settings
@Serializable
data object Updates : Settings
@Serializable
data object Debug : Settings
@Serializable
data object ExoPlayer : Settings
}
}
@@ -8,6 +8,16 @@ package net.newpipe.app.navigation
import androidx.compose.runtime.mutableStateListOf
import co.touchlab.kermit.Logger
import net.newpipe.app.screen.about.AboutScreen
import net.newpipe.app.screen.settings.backuprestore.BackupRestoreSettingsScreen
import net.newpipe.app.screen.settings.content.ContentSettingsScreen
import net.newpipe.app.screen.settings.debug.DebugScreen
import net.newpipe.app.screen.settings.download.DownloadSettingsScreen
import net.newpipe.app.screen.settings.exoplayer.ExoPlayerSettingsScreen
import net.newpipe.app.screen.settings.history.HistorySettingsScreen
import net.newpipe.app.screen.settings.home.SettingsHomeScreen
import net.newpipe.app.screen.settings.lookfeel.LookFeelSettingsScreen
import net.newpipe.app.screen.settings.player.PlayerSettingsScreen
import net.newpipe.app.screen.settings.updates.UpdatesSettingsScreen
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.core.annotation.Provided
import org.koin.core.annotation.Singleton
@@ -27,6 +37,47 @@ fun navModule() = module {
navigation<Destination.About> {
AboutScreen()
}
navigation<Destination.Settings.Home> {
SettingsHomeScreen()
}
navigation<Destination.Settings.Debug> {
DebugScreen()
}
navigation<Destination.Settings.Player> {
PlayerSettingsScreen()
}
navigation<Destination.Settings.Download> {
DownloadSettingsScreen()
}
navigation<Destination.Settings.HistoryCache> {
HistorySettingsScreen()
}
navigation<Destination.Settings.ExoPlayer> {
ExoPlayerSettingsScreen()
}
navigation<Destination.Settings.Updates> {
UpdatesSettingsScreen()
}
navigation<Destination.Settings.LookFeel> {
LookFeelSettingsScreen()
}
navigation<Destination.Settings.BackupRestore> {
BackupRestoreSettingsScreen()
}
navigation<Destination.Settings.Content> {
ContentSettingsScreen()
}
}
/**
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
/**
* Platform side-effects triggered from the Backup & Restore settings screen.
*
* Android: each action opens the appropriate SAF picker via launchers
* registered by the host activity, then runs the corresponding flow
* (DB checkpoint, ZIP I/O, optional settings import, app restart) via the
* legacy [ImportExportManager] in `:app`.
*
*/
interface BackupRestoreActions {
/** Open file picker for a NewPipe ZIP export and import it (DB + optional settings). */
fun importDatabase()
/** Open save-as picker and export DB + settings into a NewPipe ZIP. */
fun exportDatabase()
/** Clear all SharedPreferences after the screen's confirm dialog accepts, then restart. */
fun resetAllSettings()
/** Open file picker for a subscriptions .json and import them. */
fun importSubscriptions()
/** Open save-as picker and export subscriptions to .json. */
fun exportSubscriptions()
}
@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
/**
* Platform side-effects triggered from the Content settings screen.
*
* Android: opens the legacy ChooseTabsFragment / PeertubeInstanceListFragment
* via [SettingsActivity], and restarts the app when the app-language changes
* (so locale takes effect).
*
*/
interface ContentActions {
fun openMainPageTabsChooser()
fun openPeertubeInstanceList()
fun onAppLanguageChanged()
}
@@ -1,2 +1,20 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
/**
* Platform side effects triggered from the debug settings screen.
* Implemented on Android against ErrorUtil / NotificationWorker / LeakCanary.
* */
interface DebugActions {
fun crashTheApp()
fun showErrorSnackbar()
fun createErrorNotification()
fun checkNewStreams()
fun showMemoryLeaks()
fun isLeakCanaryAvailable(): Boolean
}
@@ -1,2 +1,13 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
/**
* Platform side-effects triggered from download-related settings.
*/
interface DownloadActions {
fun pickDirectory(onPicked: (uri: String) -> Unit)
}
@@ -1,2 +1,20 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
/**
* Platform side-effects triggered from the History & Cache settings screen.
*/
interface HistoryActions {
fun wipeMetadataCache()
fun deleteWatchHistory()
fun deletePlaybackStates()
fun deleteSearchHistory()
fun clearRecaptchaCookies()
fun hasRecaptchaCookies(): Boolean
}
@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
/**
* Platform side-effects triggered from the LookFeel/Appearance settings screen.
*
* Android: applies day/night mode and recreates the current Activity so the
* theme change takes effect immediately. Also opens the system captioning
* intent.
*
*/
interface LookFeelActions {
/**
* Apply the new app theme (one of the theme values from
* `app/src/main/res/values/settings_keys.xml`: light, dark, black,
* auto_device_theme) and recreate the current Activity so the change
* takes effect immediately.
*/
fun applyTheme(newThemeKey: String)
/**
* Apply the new night theme (only the "dark" / "black" values, used when
* the main theme is "auto_device_theme"). Same recreate behavior.
*/
fun applyNightTheme(newNightThemeKey: String)
/**
* Show a short toast/snackbar with the localized message
* "You can select your favorite night theme below". Matches the legacy
* fragment's behavior when the user picks the auto-device theme.
*/
fun showSelectNightThemeToast()
/** Open the system captioning settings screen (Android only; no-op elsewhere). */
fun openCaptionSettings()
}
@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
/**
* Platform side-effects triggered from the Updates settings screen.
*
* Android: calls into the legacy NewVersionWorker via [AndroidLegacyHooks].
*/
interface UpdateActions {
/** Trigger an immediate update check; shows a "checking…" toast on Android. */
fun runManualCheck()
}
@@ -0,0 +1,149 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.screen.settings.backuprestore
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.composable.ConfirmDialog
import net.newpipe.app.composable.TextPreference
import net.newpipe.app.composable.TopAppBar
import net.newpipe.app.navigation.Navigator
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.viewmodel.settings.backuprestore.BackupRestoreSettingsViewModel
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.cancel
import newpipe.shared.generated.resources.export_data_summary
import newpipe.shared.generated.resources.export_data_title
import newpipe.shared.generated.resources.export_subscriptions_summary
import newpipe.shared.generated.resources.export_subscriptions_title
import newpipe.shared.generated.resources.import_data_summary
import newpipe.shared.generated.resources.import_data_title
import newpipe.shared.generated.resources.import_subscriptions_summary
import newpipe.shared.generated.resources.import_subscriptions_title
import newpipe.shared.generated.resources.ok
import newpipe.shared.generated.resources.reset_all_settings
import newpipe.shared.generated.resources.reset_settings_summary
import newpipe.shared.generated.resources.reset_settings_title
import newpipe.shared.generated.resources.settings_category_backup_restore_title
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun BackupRestoreSettingsScreen(
navigator: Navigator = koinInject(),
viewModel: BackupRestoreSettingsViewModel = koinViewModel()
) {
BackupRestoreSettingsContent(
onNavigateUp = navigator::navigateUp,
onImportDatabase = viewModel::importDatabase,
onExportDatabase = viewModel::exportDatabase,
onResetSettings = viewModel::resetAllSettings,
onImportSubscriptions = viewModel::importSubscriptions,
onExportSubscriptions = viewModel::exportSubscriptions
)
}
@Composable
private fun BackupRestoreSettingsContent(
onNavigateUp: () -> Unit,
onImportDatabase: () -> Unit,
onExportDatabase: () -> Unit,
onResetSettings: () -> Unit,
onImportSubscriptions: () -> Unit,
onExportSubscriptions: () -> Unit
) {
var showResetConfirm by rememberSaveable { mutableStateOf(false) }
if (showResetConfirm) {
ConfirmDialog(
title = stringResource(Res.string.reset_settings_title),
message = stringResource(Res.string.reset_all_settings),
confirmLabel = stringResource(Res.string.ok),
dismissLabel = stringResource(Res.string.cancel),
onConfirm = {
showResetConfirm = false
onResetSettings()
},
onDismiss = { showResetConfirm = false }
)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title =
stringResource(Res.string.settings_category_backup_restore_title),
onNavigateUp = onNavigateUp
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
TextPreference(
title = stringResource(Res.string.import_data_title),
summary = stringResource(Res.string.import_data_summary),
onClick = onImportDatabase
)
TextPreference(
title = stringResource(Res.string.export_data_title),
summary = stringResource(Res.string.export_data_summary),
onClick = onExportDatabase
)
TextPreference(
title = stringResource(Res.string.reset_settings_title),
summary =
stringResource(Res.string.reset_settings_summary),
onClick = { showResetConfirm = true }
)
TextPreference(
title =
stringResource(Res.string.export_subscriptions_title),
summary =
stringResource(Res.string.export_subscriptions_summary),
onClick = onExportSubscriptions
)
TextPreference(
title =
stringResource(Res.string.import_subscriptions_title),
summary =
stringResource(Res.string.import_subscriptions_summary),
onClick = onImportSubscriptions
)
}
}
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun BackupRestoreSettingsScreenPreview() {
BackupRestoreSettingsContent(
onNavigateUp = {},
onImportDatabase = {},
onExportDatabase = {},
onResetSettings = {},
onImportSubscriptions = {},
onExportSubscriptions = {}
)
}
@@ -0,0 +1,516 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.screen.settings.content
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.composable.ListPreference
import net.newpipe.app.composable.ListPreferenceOption
import net.newpipe.app.composable.MultiSelectListPreference
import net.newpipe.app.composable.MultiSelectListPreferenceOption
import net.newpipe.app.composable.PreferenceCategoryHeader
import net.newpipe.app.composable.SwitchPreference
import net.newpipe.app.composable.TextPreference
import net.newpipe.app.composable.TopAppBar
import net.newpipe.app.navigation.Navigator
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.viewmodel.settings.content.CHANNEL_TAB_ABOUT
import net.newpipe.app.viewmodel.settings.content.CHANNEL_TAB_ALBUMS
import net.newpipe.app.viewmodel.settings.content.CHANNEL_TAB_CHANNELS
import net.newpipe.app.viewmodel.settings.content.CHANNEL_TAB_LIKES
import net.newpipe.app.viewmodel.settings.content.CHANNEL_TAB_LIVESTREAMS
import net.newpipe.app.viewmodel.settings.content.CHANNEL_TAB_PLAYLISTS
import net.newpipe.app.viewmodel.settings.content.CHANNEL_TAB_SHORTS
import net.newpipe.app.viewmodel.settings.content.CHANNEL_TAB_TRACKS
import net.newpipe.app.viewmodel.settings.content.CHANNEL_TAB_VIDEOS
import net.newpipe.app.viewmodel.settings.content.ContentSettingsViewModel
import net.newpipe.app.viewmodel.settings.content.FETCH_TAB_LIKES
import net.newpipe.app.viewmodel.settings.content.FETCH_TAB_LIVESTREAMS
import net.newpipe.app.viewmodel.settings.content.FETCH_TAB_SHORTS
import net.newpipe.app.viewmodel.settings.content.FETCH_TAB_TRACKS
import net.newpipe.app.viewmodel.settings.content.FETCH_TAB_VIDEOS
import net.newpipe.app.viewmodel.settings.content.IMAGE_QUALITY_HIGH
import net.newpipe.app.viewmodel.settings.content.IMAGE_QUALITY_LOW
import net.newpipe.app.viewmodel.settings.content.IMAGE_QUALITY_MEDIUM
import net.newpipe.app.viewmodel.settings.content.LOCALIZATION_SYSTEM
import net.newpipe.app.viewmodel.settings.content.SUGGESTIONS_LOCAL
import net.newpipe.app.viewmodel.settings.content.SUGGESTIONS_REMOTE
import net.newpipe.app.viewmodel.settings.content.THRESHOLD_12_HOURS
import net.newpipe.app.viewmodel.settings.content.THRESHOLD_15_MIN
import net.newpipe.app.viewmodel.settings.content.THRESHOLD_1_DAY
import net.newpipe.app.viewmodel.settings.content.THRESHOLD_1_HOUR
import net.newpipe.app.viewmodel.settings.content.THRESHOLD_5_MIN
import net.newpipe.app.viewmodel.settings.content.THRESHOLD_6_HOURS
import net.newpipe.app.viewmodel.settings.content.THRESHOLD_IMMEDIATE
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.app_language_title
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_likes
import newpipe.shared.generated.resources.channel_tab_livestreams
import newpipe.shared.generated.resources.channel_tab_playlists
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 newpipe.shared.generated.resources.content
import newpipe.shared.generated.resources.content_language_title
import newpipe.shared.generated.resources.country_au
import newpipe.shared.generated.resources.country_br
import newpipe.shared.generated.resources.country_ca
import newpipe.shared.generated.resources.country_cn
import newpipe.shared.generated.resources.country_de
import newpipe.shared.generated.resources.country_es
import newpipe.shared.generated.resources.country_fr
import newpipe.shared.generated.resources.country_gb
import newpipe.shared.generated.resources.country_in
import newpipe.shared.generated.resources.country_it
import newpipe.shared.generated.resources.country_jp
import newpipe.shared.generated.resources.country_kr
import newpipe.shared.generated.resources.country_mx
import newpipe.shared.generated.resources.country_ru
import newpipe.shared.generated.resources.country_system
import newpipe.shared.generated.resources.country_us
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_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.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_summary
import newpipe.shared.generated.resources.image_quality_title
import newpipe.shared.generated.resources.language_ar
import newpipe.shared.generated.resources.language_de
import newpipe.shared.generated.resources.language_en
import newpipe.shared.generated.resources.language_es
import newpipe.shared.generated.resources.language_fr
import newpipe.shared.generated.resources.language_hi
import newpipe.shared.generated.resources.language_it
import newpipe.shared.generated.resources.language_ja
import newpipe.shared.generated.resources.language_ko
import newpipe.shared.generated.resources.language_nl
import newpipe.shared.generated.resources.language_pl
import newpipe.shared.generated.resources.language_pt
import newpipe.shared.generated.resources.language_ru
import newpipe.shared.generated.resources.language_system
import newpipe.shared.generated.resources.language_tr
import newpipe.shared.generated.resources.language_zh
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.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.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.threshold_12_hours
import newpipe.shared.generated.resources.threshold_15_min
import newpipe.shared.generated.resources.threshold_1_day
import newpipe.shared.generated.resources.threshold_1_hour
import newpipe.shared.generated.resources.threshold_5_min
import newpipe.shared.generated.resources.threshold_6_hours
import newpipe.shared.generated.resources.threshold_immediate
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.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun ContentSettingsScreen(
navigator: Navigator = koinInject(),
viewModel: ContentSettingsViewModel = koinViewModel()
) {
val appLanguage by viewModel.appLanguage.collectAsState()
val contentLanguage by viewModel.contentLanguage.collectAsState()
val contentCountry by viewModel.contentCountry.collectAsState()
val showChannelTabs by viewModel.showChannelTabs.collectAsState()
val showSearchSuggestions by
viewModel.showSearchSuggestions.collectAsState()
val imageQuality by viewModel.imageQuality.collectAsState()
val showAgeRestricted by viewModel.showAgeRestricted.collectAsState()
val youtubeRestrictedMode by
viewModel.youtubeRestrictedMode.collectAsState()
val showComments by viewModel.showComments.collectAsState()
val showNextVideo by viewModel.showNextVideo.collectAsState()
val showDescription by viewModel.showDescription.collectAsState()
val showMetaInfo by viewModel.showMetaInfo.collectAsState()
val feedUpdateThreshold by
viewModel.feedUpdateThreshold.collectAsState()
val feedUseDedicatedFetch by
viewModel.feedUseDedicatedFetch.collectAsState()
val feedFetchChannelTabs by
viewModel.feedFetchChannelTabs.collectAsState()
ContentSettingsContent(
appLanguage = appLanguage,
contentLanguage = contentLanguage,
contentCountry = contentCountry,
showChannelTabs = showChannelTabs,
showSearchSuggestions = showSearchSuggestions,
imageQuality = imageQuality,
showAgeRestricted = showAgeRestricted,
youtubeRestrictedMode = youtubeRestrictedMode,
showComments = showComments,
showNextVideo = showNextVideo,
showDescription = showDescription,
showMetaInfo = showMetaInfo,
feedUpdateThreshold = feedUpdateThreshold,
feedUseDedicatedFetch = feedUseDedicatedFetch,
feedFetchChannelTabs = feedFetchChannelTabs,
onNavigateUp = navigator::navigateUp,
onSetAppLanguage = viewModel::setAppLanguage,
onSetContentLanguage = viewModel::setContentLanguage,
onSetContentCountry = viewModel::setContentCountry,
onOpenMainPageTabs = viewModel::openMainPageTabs,
onSetShowChannelTabs = viewModel::setShowChannelTabs,
onOpenPeertubeInstances = viewModel::openPeertubeInstances,
onToggleShowAgeRestricted = viewModel::toggleShowAgeRestricted,
onToggleYoutubeRestrictedMode =
viewModel::toggleYoutubeRestrictedMode,
onSetShowSearchSuggestions = viewModel::setShowSearchSuggestions,
onSetImageQuality = viewModel::setImageQuality,
onToggleShowComments = viewModel::toggleShowComments,
onToggleShowNextVideo = viewModel::toggleShowNextVideo,
onToggleShowDescription = viewModel::toggleShowDescription,
onToggleShowMetaInfo = viewModel::toggleShowMetaInfo,
onSetFeedUpdateThreshold = viewModel::setFeedUpdateThreshold,
onToggleFeedUseDedicatedFetch =
viewModel::toggleFeedUseDedicatedFetch,
onSetFeedFetchChannelTabs = viewModel::setFeedFetchChannelTabs
)
}
@Suppress("LongParameterList", "LongMethod")
@Composable
private fun ContentSettingsContent(
appLanguage: String,
contentLanguage: String,
contentCountry: String,
showChannelTabs: Set<String>,
showSearchSuggestions: Set<String>,
imageQuality: String,
showAgeRestricted: Boolean,
youtubeRestrictedMode: Boolean,
showComments: Boolean,
showNextVideo: Boolean,
showDescription: Boolean,
showMetaInfo: Boolean,
feedUpdateThreshold: String,
feedUseDedicatedFetch: Boolean,
feedFetchChannelTabs: Set<String>,
onNavigateUp: () -> Unit,
onSetAppLanguage: (String) -> Unit,
onSetContentLanguage: (String) -> Unit,
onSetContentCountry: (String) -> Unit,
onOpenMainPageTabs: () -> Unit,
onSetShowChannelTabs: (Set<String>) -> Unit,
onOpenPeertubeInstances: () -> Unit,
onToggleShowAgeRestricted: (Boolean) -> Unit,
onToggleYoutubeRestrictedMode: (Boolean) -> Unit,
onSetShowSearchSuggestions: (Set<String>) -> Unit,
onSetImageQuality: (String) -> Unit,
onToggleShowComments: (Boolean) -> Unit,
onToggleShowNextVideo: (Boolean) -> Unit,
onToggleShowDescription: (Boolean) -> Unit,
onToggleShowMetaInfo: (Boolean) -> Unit,
onSetFeedUpdateThreshold: (String) -> Unit,
onToggleFeedUseDedicatedFetch: (Boolean) -> Unit,
onSetFeedFetchChannelTabs: (Set<String>) -> Unit
) {
val languageOptions = languageOptions()
val countryOptions = countryOptions()
val channelTabOptions = channelTabOptions()
val suggestionOptions = suggestionOptions()
val imageQualityOptions = imageQualityOptions()
val thresholdOptions = thresholdOptions()
val fetchTabOptions = fetchTabOptions()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = stringResource(Res.string.content),
onNavigateUp = onNavigateUp
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
ListPreference(
title = stringResource(Res.string.app_language_title),
options = languageOptions,
selectedValue = appLanguage,
onValueChange = onSetAppLanguage
)
ListPreference(
title = stringResource(Res.string.content_language_title),
options = languageOptions,
selectedValue = contentLanguage,
onValueChange = onSetContentLanguage
)
ListPreference(
title = stringResource(Res.string.default_content_country_title),
options = countryOptions,
selectedValue = contentCountry,
onValueChange = onSetContentCountry
)
TextPreference(
title = stringResource(Res.string.main_page_content),
summary = stringResource(Res.string.main_page_content_summary),
onClick = onOpenMainPageTabs
)
MultiSelectListPreference(
title = stringResource(Res.string.show_channel_tabs),
summary = stringResource(Res.string.show_channel_tabs_summary),
options = channelTabOptions,
selectedValues = showChannelTabs,
onValuesChange = onSetShowChannelTabs
)
TextPreference(
title = stringResource(Res.string.peertube_instance_url_title),
summary = stringResource(Res.string.peertube_instance_url_summary),
onClick = onOpenPeertubeInstances
)
SwitchPreference(
title = stringResource(Res.string.show_age_restricted_content_title),
summary = stringResource(Res.string.show_age_restricted_content_summary),
isChecked = showAgeRestricted,
onCheckedChange = onToggleShowAgeRestricted
)
SwitchPreference(
title = stringResource(Res.string.youtube_restricted_mode_enabled_title),
summary = stringResource(Res.string.youtube_restricted_mode_enabled_summary),
isChecked = youtubeRestrictedMode,
onCheckedChange = onToggleYoutubeRestrictedMode
)
MultiSelectListPreference(
title = stringResource(Res.string.show_search_suggestions_title),
summary = stringResource(Res.string.show_search_suggestions_summary),
options = suggestionOptions,
selectedValues = showSearchSuggestions,
onValuesChange = onSetShowSearchSuggestions
)
ListPreference(
title = stringResource(Res.string.image_quality_title),
summary = stringResource(Res.string.image_quality_summary),
options = imageQualityOptions,
selectedValue = imageQuality,
onValueChange = onSetImageQuality
)
SwitchPreference(
title = stringResource(Res.string.show_comments_title),
summary = stringResource(Res.string.show_comments_summary),
isChecked = showComments,
onCheckedChange = onToggleShowComments
)
SwitchPreference(
title = stringResource(Res.string.show_next_and_similar_title),
isChecked = showNextVideo,
onCheckedChange = onToggleShowNextVideo
)
SwitchPreference(
title = stringResource(Res.string.show_description_title),
summary = stringResource(Res.string.show_description_summary),
isChecked = showDescription,
onCheckedChange = onToggleShowDescription
)
SwitchPreference(
title = stringResource(Res.string.show_meta_info_title),
summary = stringResource(Res.string.show_meta_info_summary),
isChecked = showMetaInfo,
onCheckedChange = onToggleShowMetaInfo
)
PreferenceCategoryHeader(
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),
options = thresholdOptions,
selectedValue = feedUpdateThreshold,
onValueChange = onSetFeedUpdateThreshold
)
SwitchPreference(
title = stringResource(Res.string.feed_use_dedicated_fetch_method_title),
summary = stringResource(Res.string.feed_use_dedicated_fetch_method_summary),
isChecked = feedUseDedicatedFetch,
onCheckedChange = onToggleFeedUseDedicatedFetch
)
MultiSelectListPreference(
title = stringResource(Res.string.feed_fetch_channel_tabs),
summary = stringResource(Res.string.feed_fetch_channel_tabs_summary),
options = fetchTabOptions,
selectedValues = feedFetchChannelTabs,
onValuesChange = onSetFeedFetchChannelTabs
)
}
}
}
// Option lists (curated; see TODO at file top)
@Composable
private fun languageOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(LOCALIZATION_SYSTEM, stringResource(Res.string.language_system)),
ListPreferenceOption("en", stringResource(Res.string.language_en)),
ListPreferenceOption("es", stringResource(Res.string.language_es)),
ListPreferenceOption("fr", stringResource(Res.string.language_fr)),
ListPreferenceOption("de", stringResource(Res.string.language_de)),
ListPreferenceOption("pt", stringResource(Res.string.language_pt)),
ListPreferenceOption("ru", stringResource(Res.string.language_ru)),
ListPreferenceOption("zh", stringResource(Res.string.language_zh)),
ListPreferenceOption("ja", stringResource(Res.string.language_ja)),
ListPreferenceOption("ko", stringResource(Res.string.language_ko)),
ListPreferenceOption("it", stringResource(Res.string.language_it)),
ListPreferenceOption("nl", stringResource(Res.string.language_nl)),
ListPreferenceOption("pl", stringResource(Res.string.language_pl)),
ListPreferenceOption("tr", stringResource(Res.string.language_tr)),
ListPreferenceOption("ar", stringResource(Res.string.language_ar)),
ListPreferenceOption("hi", stringResource(Res.string.language_hi))
)
@Composable
private fun countryOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(LOCALIZATION_SYSTEM, stringResource(Res.string.country_system)),
ListPreferenceOption("US", stringResource(Res.string.country_us)),
ListPreferenceOption("GB", stringResource(Res.string.country_gb)),
ListPreferenceOption("DE", stringResource(Res.string.country_de)),
ListPreferenceOption("FR", stringResource(Res.string.country_fr)),
ListPreferenceOption("ES", stringResource(Res.string.country_es)),
ListPreferenceOption("IT", stringResource(Res.string.country_it)),
ListPreferenceOption("BR", stringResource(Res.string.country_br)),
ListPreferenceOption("JP", stringResource(Res.string.country_jp)),
ListPreferenceOption("KR", stringResource(Res.string.country_kr)),
ListPreferenceOption("IN", stringResource(Res.string.country_in)),
ListPreferenceOption("RU", stringResource(Res.string.country_ru)),
ListPreferenceOption("CN", stringResource(Res.string.country_cn)),
ListPreferenceOption("AU", stringResource(Res.string.country_au)),
ListPreferenceOption("CA", stringResource(Res.string.country_ca)),
ListPreferenceOption("MX", stringResource(Res.string.country_mx))
)
@Composable
private fun channelTabOptions(): List<MultiSelectListPreferenceOption> =
listOf(
MultiSelectListPreferenceOption(CHANNEL_TAB_VIDEOS, stringResource(Res.string.channel_tab_videos)),
MultiSelectListPreferenceOption(CHANNEL_TAB_TRACKS, stringResource(Res.string.channel_tab_tracks)),
MultiSelectListPreferenceOption(CHANNEL_TAB_SHORTS, stringResource(Res.string.channel_tab_shorts)),
MultiSelectListPreferenceOption(CHANNEL_TAB_LIVESTREAMS, stringResource(Res.string.channel_tab_livestreams)),
MultiSelectListPreferenceOption(CHANNEL_TAB_CHANNELS, stringResource(Res.string.channel_tab_channels)),
MultiSelectListPreferenceOption(CHANNEL_TAB_PLAYLISTS, stringResource(Res.string.channel_tab_playlists)),
MultiSelectListPreferenceOption(CHANNEL_TAB_ALBUMS, stringResource(Res.string.channel_tab_albums)),
MultiSelectListPreferenceOption(CHANNEL_TAB_LIKES, stringResource(Res.string.channel_tab_likes)),
MultiSelectListPreferenceOption(CHANNEL_TAB_ABOUT, stringResource(Res.string.channel_tab_about))
)
@Composable
private fun suggestionOptions(): List<MultiSelectListPreferenceOption> =
listOf(
MultiSelectListPreferenceOption(SUGGESTIONS_LOCAL, stringResource(Res.string.local_search_suggestions)),
MultiSelectListPreferenceOption(SUGGESTIONS_REMOTE, stringResource(Res.string.remote_search_suggestions))
)
@Composable
private fun imageQualityOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(IMAGE_QUALITY_LOW, stringResource(Res.string.image_quality_low)),
ListPreferenceOption(IMAGE_QUALITY_MEDIUM, stringResource(Res.string.image_quality_medium)),
ListPreferenceOption(IMAGE_QUALITY_HIGH, stringResource(Res.string.image_quality_high))
)
@Composable
private fun thresholdOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(THRESHOLD_IMMEDIATE, stringResource(Res.string.threshold_immediate)),
ListPreferenceOption(THRESHOLD_5_MIN, stringResource(Res.string.threshold_5_min)),
ListPreferenceOption(THRESHOLD_15_MIN, stringResource(Res.string.threshold_15_min)),
ListPreferenceOption(THRESHOLD_1_HOUR, stringResource(Res.string.threshold_1_hour)),
ListPreferenceOption(THRESHOLD_6_HOURS, stringResource(Res.string.threshold_6_hours)),
ListPreferenceOption(THRESHOLD_12_HOURS, stringResource(Res.string.threshold_12_hours)),
ListPreferenceOption(THRESHOLD_1_DAY, stringResource(Res.string.threshold_1_day))
)
@Composable
private fun fetchTabOptions(): List<MultiSelectListPreferenceOption> =
listOf(
MultiSelectListPreferenceOption(FETCH_TAB_VIDEOS, stringResource(Res.string.channel_tab_videos)),
MultiSelectListPreferenceOption(FETCH_TAB_TRACKS, stringResource(Res.string.channel_tab_tracks)),
MultiSelectListPreferenceOption(FETCH_TAB_SHORTS, stringResource(Res.string.channel_tab_shorts)),
MultiSelectListPreferenceOption(FETCH_TAB_LIVESTREAMS, stringResource(Res.string.channel_tab_livestreams)),
MultiSelectListPreferenceOption(FETCH_TAB_LIKES, stringResource(Res.string.channel_tab_likes))
)
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun ContentSettingsScreenPreview() {
ContentSettingsContent(
appLanguage = LOCALIZATION_SYSTEM,
contentLanguage = LOCALIZATION_SYSTEM,
contentCountry = LOCALIZATION_SYSTEM,
showChannelTabs = setOf(CHANNEL_TAB_VIDEOS, CHANNEL_TAB_TRACKS, CHANNEL_TAB_PLAYLISTS),
showSearchSuggestions = setOf(SUGGESTIONS_LOCAL, SUGGESTIONS_REMOTE),
imageQuality = IMAGE_QUALITY_MEDIUM,
showAgeRestricted = false,
youtubeRestrictedMode = false,
showComments = true,
showNextVideo = true,
showDescription = true,
showMetaInfo = true,
feedUpdateThreshold = THRESHOLD_5_MIN,
feedUseDedicatedFetch = false,
feedFetchChannelTabs = setOf(FETCH_TAB_VIDEOS, FETCH_TAB_LIVESTREAMS),
onNavigateUp = {},
onSetAppLanguage = {},
onSetContentLanguage = {},
onSetContentCountry = {},
onOpenMainPageTabs = {},
onSetShowChannelTabs = {},
onOpenPeertubeInstances = {},
onToggleShowAgeRestricted = {},
onToggleYoutubeRestrictedMode = {},
onSetShowSearchSuggestions = {},
onSetImageQuality = {},
onToggleShowComments = {},
onToggleShowNextVideo = {},
onToggleShowDescription = {},
onToggleShowMetaInfo = {},
onSetFeedUpdateThreshold = {},
onToggleFeedUseDedicatedFetch = {},
onSetFeedFetchChannelTabs = {}
)
}
@@ -0,0 +1,222 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.screen.settings.debug
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.composable.SwitchPreference
import net.newpipe.app.composable.TextPreference
import net.newpipe.app.composable.TopAppBar
import net.newpipe.app.navigation.Navigator
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.viewmodel.settings.SettingsViewModel
import net.newpipe.app.viewmodel.settings.debug.DebugSettingsViewModel
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.check_new_streams
import newpipe.shared.generated.resources.crash_the_app
import newpipe.shared.generated.resources.create_error_notification
import newpipe.shared.generated.resources.enable_disposed_exceptions_summary
import newpipe.shared.generated.resources.enable_disposed_exceptions_title
import newpipe.shared.generated.resources.enable_leak_canary_summary
import newpipe.shared.generated.resources.leak_canary_not_available
import newpipe.shared.generated.resources.leakcanary
import newpipe.shared.generated.resources.settings_category_debug_title
import newpipe.shared.generated.resources.settings_layout_redesign
import newpipe.shared.generated.resources.show_crash_the_player_summary
import newpipe.shared.generated.resources.show_crash_the_player_title
import newpipe.shared.generated.resources.show_error_snackbar
import newpipe.shared.generated.resources.show_memory_leaks
import newpipe.shared.generated.resources.show_original_time_ago_summary
import newpipe.shared.generated.resources.show_original_time_ago_title
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun DebugScreen(
navigator: Navigator = koinInject(),
debugVm: DebugSettingsViewModel = koinViewModel(),
settingsVm: SettingsViewModel = koinViewModel()
) {
val settingsLayoutRedesign by settingsVm.settingsLayoutRedesign.collectAsState()
val allowHeapDumping by debugVm.allowHeapDumping.collectAsState()
val allowDisposedExceptions by debugVm.allowDisposedExceptions.collectAsState()
val showOriginalTimeAgo by debugVm.showOriginalTimeAgo.collectAsState()
val showCrashThePlayer by debugVm.showCrashThePlayer.collectAsState()
DebugScreenContent(
settingsLayoutRedesign = settingsLayoutRedesign,
isLeakCanaryAvailable = debugVm.isLeakCanaryAvailable,
allowHeapDumping = allowHeapDumping,
allowDisposedExceptions = allowDisposedExceptions,
showOriginalTimeAgo = showOriginalTimeAgo,
showCrashThePlayer = showCrashThePlayer,
onNavigateUp = navigator::navigateUp,
onToggleHeapDumping = debugVm::toggleAllowHeapDumping,
onShowMemoryLeaks = debugVm::showMemoryLeaks,
onToggleDisposedExceptions = debugVm::toggleAllowDisposedExceptions,
onToggleOriginalTimeAgo = debugVm::toggleShowOriginalTimeAgo,
onToggleCrashThePlayer = debugVm::toggleShowCrashThePlayer,
onCheckNewStreams = debugVm::checkNewStreams,
onCrashTheApp = debugVm::crashTheApp,
onShowErrorSnackbar = debugVm::showErrorSnackbar,
onCreateErrorNotification = debugVm::createErrorNotification,
onToggleSettingsLayoutRedesign = settingsVm::toggleSettingsLayoutRedesign
)
}
@Composable
private fun DebugScreenContent(
settingsLayoutRedesign: Boolean,
isLeakCanaryAvailable: Boolean,
allowHeapDumping: Boolean,
allowDisposedExceptions: Boolean,
showOriginalTimeAgo: Boolean,
showCrashThePlayer: Boolean,
onNavigateUp: () -> Unit,
onToggleHeapDumping: (Boolean) -> Unit,
onShowMemoryLeaks: () -> Unit,
onToggleDisposedExceptions: (Boolean) -> Unit,
onToggleOriginalTimeAgo: (Boolean) -> Unit,
onToggleCrashThePlayer: (Boolean) -> Unit,
onCheckNewStreams: () -> Unit,
onCrashTheApp: () -> Unit,
onShowErrorSnackbar: () -> Unit,
onCreateErrorNotification: () -> Unit,
onToggleSettingsLayoutRedesign: (Boolean) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = stringResource(Res.string.settings_category_debug_title),
onNavigateUp = onNavigateUp
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
SwitchPreference(
title = stringResource(Res.string.leakcanary),
summary = stringResource(
if (isLeakCanaryAvailable)
Res.string.enable_leak_canary_summary
else Res.string.leak_canary_not_available
),
isChecked = allowHeapDumping,
onCheckedChange = onToggleHeapDumping,
enabled = isLeakCanaryAvailable
)
TextPreference(
title = stringResource(Res.string.show_memory_leaks),
onClick = onShowMemoryLeaks,
enabled = isLeakCanaryAvailable
)
SwitchPreference(
title = stringResource(Res.string.enable_disposed_exceptions_title),
summary = stringResource(Res.string.enable_disposed_exceptions_summary),
isChecked = allowDisposedExceptions,
onCheckedChange = onToggleDisposedExceptions
)
SwitchPreference(
title = stringResource(Res.string.show_original_time_ago_title),
summary = stringResource(Res.string.show_original_time_ago_summary),
isChecked = showOriginalTimeAgo,
onCheckedChange = onToggleOriginalTimeAgo
)
SwitchPreference(
title = stringResource(Res.string.show_crash_the_player_title),
summary = stringResource(Res.string.show_crash_the_player_summary),
isChecked = showCrashThePlayer,
onCheckedChange = onToggleCrashThePlayer
)
TextPreference(
title = stringResource(Res.string.check_new_streams),
onClick = onCheckNewStreams
)
TextPreference(
title = stringResource(Res.string.crash_the_app),
onClick = onCrashTheApp
)
TextPreference(
title = stringResource(Res.string.show_error_snackbar),
onClick = onShowErrorSnackbar
)
TextPreference(
title = stringResource(Res.string.create_error_notification),
onClick = onCreateErrorNotification
)
SwitchPreference(
title = stringResource(Res.string.settings_layout_redesign),
isChecked = settingsLayoutRedesign,
onCheckedChange = onToggleSettingsLayoutRedesign
)
}
}
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun DebugScreenPreview() {
DebugScreenContent(
settingsLayoutRedesign = true,
isLeakCanaryAvailable = true,
allowHeapDumping = false,
allowDisposedExceptions = false,
showOriginalTimeAgo = false,
showCrashThePlayer = false,
onNavigateUp = {},
onToggleHeapDumping = {},
onShowMemoryLeaks = {},
onToggleDisposedExceptions = {},
onToggleOriginalTimeAgo = {},
onToggleCrashThePlayer = {},
onCheckNewStreams = {},
onCrashTheApp = {},
onShowErrorSnackbar = {},
onCreateErrorNotification = {},
onToggleSettingsLayoutRedesign = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun DebugScreenNoLeakCanaryPreview() {
DebugScreenContent(
settingsLayoutRedesign = false,
isLeakCanaryAvailable = false,
allowHeapDumping = false,
allowDisposedExceptions = false,
showOriginalTimeAgo = false,
showCrashThePlayer = false,
onNavigateUp = {},
onToggleHeapDumping = {},
onShowMemoryLeaks = {},
onToggleDisposedExceptions = {},
onToggleOriginalTimeAgo = {},
onToggleCrashThePlayer = {},
onCheckNewStreams = {},
onCrashTheApp = {},
onShowErrorSnackbar = {},
onCreateErrorNotification = {},
onToggleSettingsLayoutRedesign = {}
)
}
@@ -1,2 +1,124 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.screen.settings.download
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.composable.SwitchPreference
import net.newpipe.app.composable.TextPreference
import net.newpipe.app.composable.TopAppBar
import net.newpipe.app.navigation.Navigator
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.viewmodel.settings.download.DownloadSettingsViewModel
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.download_path_audio_title
import newpipe.shared.generated.resources.download_path_title
import newpipe.shared.generated.resources.downloads_storage_ask_summary
import newpipe.shared.generated.resources.downloads_storage_ask_title
import newpipe.shared.generated.resources.downloads_storage_use_saf_summary
import newpipe.shared.generated.resources.downloads_storage_use_saf_title
import newpipe.shared.generated.resources.settings_category_downloads_title
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun DownloadSettingsScreen(
navigator: Navigator = koinInject(),
vm: DownloadSettingsViewModel = koinViewModel()
) {
val storageAsk by vm.storageAsk.collectAsState()
val useSaf by vm.useSaf.collectAsState()
val videoPath by vm.videoPath.collectAsState()
val audioPath by vm.audioPath.collectAsState()
DownloadSettingsContent(
storageAsk = storageAsk,
useSaf = useSaf,
videoPath = videoPath,
audioPath = audioPath,
onNavigateUp = navigator::navigateUp,
onToggleStorageAsk = vm::toggleStorageAsk,
onToggleUseSaf = vm::toggleUseSaf,
onPickVideoPath = vm::pickVideoPath,
onPickAudioPath = vm::pickAudioPath
)
}
@Composable
private fun DownloadSettingsContent(
storageAsk: Boolean,
useSaf: Boolean,
videoPath: String,
audioPath: String,
onNavigateUp: () -> Unit,
onToggleStorageAsk: (Boolean) -> Unit,
onToggleUseSaf: (Boolean) -> Unit,
onPickVideoPath: () -> Unit,
onPickAudioPath: () -> Unit
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title =
stringResource(Res.string.settings_category_downloads_title),
onNavigateUp = onNavigateUp
)
}
) { padding ->
Column(modifier = Modifier.padding(padding)) {
SwitchPreference(
title = stringResource(Res.string.downloads_storage_ask_title),
summary = stringResource(Res.string.downloads_storage_ask_summary),
isChecked = storageAsk,
onCheckedChange = onToggleStorageAsk
)
SwitchPreference(
title = stringResource(Res.string.downloads_storage_use_saf_title),
summary = stringResource(Res.string.downloads_storage_use_saf_summary),
isChecked = useSaf,
onCheckedChange = onToggleUseSaf
)
TextPreference(
title = stringResource(Res.string.download_path_title),
summary = videoPath.takeIf { it.isNotEmpty() },
onClick = onPickVideoPath
)
TextPreference(
title = stringResource(Res.string.download_path_audio_title),
summary = audioPath.takeIf { it.isNotEmpty() },
onClick = onPickAudioPath
)
}
}
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun DownloadSettingsScreenPreview() {
DownloadSettingsContent(
storageAsk = false,
useSaf = true,
videoPath = "content://com.android.externalstorage/tree/primary%3AMovies",
audioPath = "",
onNavigateUp = {},
onToggleStorageAsk = {},
onToggleUseSaf = {},
onPickVideoPath = {},
onPickAudioPath = {}
)
}
@@ -0,0 +1,176 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.screen.settings.exoplayer
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.composable.ListPreference
import net.newpipe.app.composable.ListPreferenceOption
import net.newpipe.app.composable.SwitchPreference
import net.newpipe.app.composable.TopAppBar
import net.newpipe.app.navigation.Navigator
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.viewmodel.settings.exoplayer.ExoPlayerSettingsViewModel
import net.newpipe.app.viewmodel.settings.exoplayer.PROGRESSIVE_LOAD_INTERVAL_1
import net.newpipe.app.viewmodel.settings.exoplayer.PROGRESSIVE_LOAD_INTERVAL_16
import net.newpipe.app.viewmodel.settings.exoplayer.PROGRESSIVE_LOAD_INTERVAL_256
import net.newpipe.app.viewmodel.settings.exoplayer.PROGRESSIVE_LOAD_INTERVAL_64
import net.newpipe.app.viewmodel.settings.exoplayer.PROGRESSIVE_LOAD_INTERVAL_EXOPLAYER_DEFAULT
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.always_use_exoplayer_set_output_surface_workaround_summary
import newpipe.shared.generated.resources.always_use_exoplayer_set_output_surface_workaround_title
import newpipe.shared.generated.resources.disable_media_tunneling_summary
import newpipe.shared.generated.resources.disable_media_tunneling_title
import newpipe.shared.generated.resources.progressive_load_interval_1_kib
import newpipe.shared.generated.resources.progressive_load_interval_16_kib
import newpipe.shared.generated.resources.progressive_load_interval_256_kib
import newpipe.shared.generated.resources.progressive_load_interval_64_kib
import newpipe.shared.generated.resources.progressive_load_interval_exoplayer_default
import newpipe.shared.generated.resources.progressive_load_interval_summary
import newpipe.shared.generated.resources.progressive_load_interval_title
import newpipe.shared.generated.resources.settings_category_exoplayer_title
import newpipe.shared.generated.resources.use_exoplayer_decoder_fallback_summary
import newpipe.shared.generated.resources.use_exoplayer_decoder_fallback_title
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun ExoPlayerSettingsScreen(
navigator: Navigator = koinInject(),
viewModel: ExoPlayerSettingsViewModel = koinViewModel()
) {
val progressiveLoadInterval by viewModel.progressiveLoadInterval.collectAsState()
val useDecoderFallback by viewModel.useDecoderFallback.collectAsState()
val disableMediaTunneling by viewModel.disableMediaTunneling.collectAsState()
val alwaysUseSetOutputSurfaceWorkaround by
viewModel.alwaysUseSetOutputSurfaceWorkaround.collectAsState()
ExoPlayerSettingsContent(
progressiveLoadInterval = progressiveLoadInterval,
useDecoderFallback = useDecoderFallback,
disableMediaTunneling = disableMediaTunneling,
alwaysUseSetOutputSurfaceWorkaround = alwaysUseSetOutputSurfaceWorkaround,
onNavigateUp = navigator::navigateUp,
onSetProgressiveLoadInterval = viewModel::setProgressiveLoadInterval,
onToggleUseDecoderFallback = viewModel::toggleUseDecoderFallback,
onToggleDisableMediaTunneling = viewModel::toggleDisableMediaTunneling,
onToggleAlwaysUseSetOutputSurfaceWorkaround =
viewModel::toggleAlwaysUseSetOutputSurfaceWorkaround
)
}
@Composable
private fun ExoPlayerSettingsContent(
progressiveLoadInterval: String,
useDecoderFallback: Boolean,
disableMediaTunneling: Boolean,
alwaysUseSetOutputSurfaceWorkaround: Boolean,
onNavigateUp: () -> Unit,
onSetProgressiveLoadInterval: (String) -> Unit,
onToggleUseDecoderFallback: (Boolean) -> Unit,
onToggleDisableMediaTunneling: (Boolean) -> Unit,
onToggleAlwaysUseSetOutputSurfaceWorkaround: (Boolean) -> Unit
) {
val intervalOptions = progressiveLoadIntervalOptions()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = stringResource(Res.string.settings_category_exoplayer_title),
onNavigateUp = onNavigateUp
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
ListPreference(
title = stringResource(Res.string.progressive_load_interval_title),
options = intervalOptions,
selectedValue = progressiveLoadInterval,
onValueChange = onSetProgressiveLoadInterval
)
SwitchPreference(
title = stringResource(Res.string.use_exoplayer_decoder_fallback_title),
summary = stringResource(Res.string.use_exoplayer_decoder_fallback_summary),
isChecked = useDecoderFallback,
onCheckedChange = onToggleUseDecoderFallback
)
SwitchPreference(
title = stringResource(Res.string.disable_media_tunneling_title),
summary = stringResource(Res.string.disable_media_tunneling_summary),
isChecked = disableMediaTunneling,
onCheckedChange = onToggleDisableMediaTunneling
)
SwitchPreference(
title = stringResource(
Res.string.always_use_exoplayer_set_output_surface_workaround_title
),
summary = stringResource(
Res.string.always_use_exoplayer_set_output_surface_workaround_summary
),
isChecked = alwaysUseSetOutputSurfaceWorkaround,
onCheckedChange = onToggleAlwaysUseSetOutputSurfaceWorkaround
)
}
}
}
@Composable
private fun progressiveLoadIntervalOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(
PROGRESSIVE_LOAD_INTERVAL_1,
stringResource(Res.string.progressive_load_interval_1_kib)
),
ListPreferenceOption(
PROGRESSIVE_LOAD_INTERVAL_16,
stringResource(Res.string.progressive_load_interval_16_kib)
),
ListPreferenceOption(
PROGRESSIVE_LOAD_INTERVAL_64,
stringResource(Res.string.progressive_load_interval_64_kib)
),
ListPreferenceOption(
PROGRESSIVE_LOAD_INTERVAL_256,
stringResource(Res.string.progressive_load_interval_256_kib)
),
ListPreferenceOption(
PROGRESSIVE_LOAD_INTERVAL_EXOPLAYER_DEFAULT,
stringResource(Res.string.progressive_load_interval_exoplayer_default)
)
)
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun ExoPlayerSettingsScreenPreview() {
ExoPlayerSettingsContent(
progressiveLoadInterval = PROGRESSIVE_LOAD_INTERVAL_64,
useDecoderFallback = false,
disableMediaTunneling = false,
alwaysUseSetOutputSurfaceWorkaround = false,
onNavigateUp = {},
onSetProgressiveLoadInterval = {},
onToggleUseDecoderFallback = {},
onToggleDisableMediaTunneling = {},
onToggleAlwaysUseSetOutputSurfaceWorkaround = {}
)
}
@@ -1,2 +1,263 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.screen.settings.history
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.composable.ConfirmDialog
import net.newpipe.app.composable.PreferenceCategoryHeader
import net.newpipe.app.composable.SwitchPreference
import net.newpipe.app.composable.TextPreference
import net.newpipe.app.composable.TopAppBar
import net.newpipe.app.navigation.Navigator
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.viewmodel.settings.history.HistorySettingsViewModel
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.cancel
import newpipe.shared.generated.resources.clear_cookie_summary
import newpipe.shared.generated.resources.clear_cookie_title
import newpipe.shared.generated.resources.clear_playback_states_summary
import newpipe.shared.generated.resources.clear_playback_states_title
import newpipe.shared.generated.resources.clear_search_history_summary
import newpipe.shared.generated.resources.clear_search_history_title
import newpipe.shared.generated.resources.clear_views_history_summary
import newpipe.shared.generated.resources.clear_views_history_title
import newpipe.shared.generated.resources.delete
import newpipe.shared.generated.resources.enable_playback_resume_summary
import newpipe.shared.generated.resources.enable_playback_resume_title
import newpipe.shared.generated.resources.enable_playback_state_lists_summary
import newpipe.shared.generated.resources.enable_playback_state_lists_title
import newpipe.shared.generated.resources.enable_search_history_summary
import newpipe.shared.generated.resources.enable_search_history_title
import newpipe.shared.generated.resources.enable_watch_history_summary
import newpipe.shared.generated.resources.enable_watch_history_title
import newpipe.shared.generated.resources.metadata_cache_wipe_summary
import newpipe.shared.generated.resources.metadata_cache_wipe_title
import newpipe.shared.generated.resources.settings_category_clear_data_title
import newpipe.shared.generated.resources.settings_category_history_title
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
private enum class PendingDelete { WATCH, PLAYBACK_STATES, SEARCH }
@Composable
fun HistorySettingsScreen(
navigator: Navigator = koinInject(),
viewModel: HistorySettingsViewModel = koinViewModel()
) {
val watchHistory by viewModel.watchHistory.collectAsState()
val playbackResume by viewModel.playbackResume.collectAsState()
val playbackStateLists by viewModel.playbackStateLists.collectAsState()
val searchHistory by viewModel.searchHistory.collectAsState()
val hasCookies by viewModel.hasRecaptchaCookies.collectAsState()
HistorySettingsContent(
watchHistory = watchHistory,
playbackResume = playbackResume,
playbackStateLists = playbackStateLists,
searchHistory = searchHistory,
hasRecaptchaCookies = hasCookies,
onNavigateUp = navigator::navigateUp,
onToggleWatchHistory = viewModel::toggleWatchHistory,
onTogglePlaybackResume = viewModel::togglePlaybackResume,
onTogglePlaybackStateLists = viewModel::togglePlaybackStateLists,
onToggleSearchHistory = viewModel::toggleSearchHistory,
onWipeMetadataCache = viewModel::wipeMetadataCache,
onDeleteWatchHistory = viewModel::deleteWatchHistory,
onDeletePlaybackStates = viewModel::deletePlaybackStates,
onDeleteSearchHistory = viewModel::deleteSearchHistory,
onClearCookies = viewModel::clearRecaptchaCookies
)
}
@Composable
private fun HistorySettingsContent(
watchHistory: Boolean,
playbackResume: Boolean,
playbackStateLists: Boolean,
searchHistory: Boolean,
hasRecaptchaCookies: Boolean,
onNavigateUp: () -> Unit,
onToggleWatchHistory: (Boolean) -> Unit,
onTogglePlaybackResume: (Boolean) -> Unit,
onTogglePlaybackStateLists: (Boolean) -> Unit,
onToggleSearchHistory: (Boolean) -> Unit,
onWipeMetadataCache: () -> Unit,
onDeleteWatchHistory: () -> Unit,
onDeletePlaybackStates: () -> Unit,
onDeleteSearchHistory: () -> Unit,
onClearCookies: () -> Unit
) {
var pending by rememberSaveable { mutableStateOf<PendingDelete?>(null)
}
pending?.let { which ->
val (title, message, action) = when (which) {
PendingDelete.WATCH -> Triple(
stringResource(Res.string.clear_views_history_title),
stringResource(Res.string.clear_views_history_summary),
onDeleteWatchHistory
)
PendingDelete.PLAYBACK_STATES -> Triple(
stringResource(Res.string.clear_playback_states_title),
stringResource(Res.string.clear_playback_states_summary),
onDeletePlaybackStates
)
PendingDelete.SEARCH -> Triple(
stringResource(Res.string.clear_search_history_title),
stringResource(Res.string.clear_search_history_summary),
onDeleteSearchHistory
)
}
ConfirmDialog(
title = title,
message = message,
confirmLabel = stringResource(Res.string.delete),
dismissLabel = stringResource(Res.string.cancel),
onConfirm = {
action()
pending = null
},
onDismiss = { pending = null }
)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = stringResource(Res.string.settings_category_history_title),
onNavigateUp = onNavigateUp
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
SwitchPreference(
title = stringResource(Res.string.enable_watch_history_title),
summary = stringResource(Res.string.enable_watch_history_summary),
isChecked = watchHistory,
onCheckedChange = onToggleWatchHistory
)
SwitchPreference(
title = stringResource(Res.string.enable_playback_resume_title),
summary = stringResource(Res.string.enable_playback_resume_summary),
isChecked = playbackResume,
onCheckedChange = onTogglePlaybackResume,
enabled = watchHistory
)
SwitchPreference(
title = stringResource(Res.string.enable_playback_state_lists_title),
summary = stringResource(Res.string.enable_playback_state_lists_summary),
isChecked = playbackStateLists,
onCheckedChange = onTogglePlaybackStateLists,
enabled = watchHistory
)
SwitchPreference(
title = stringResource(Res.string.enable_search_history_title),
summary = stringResource(Res.string.enable_search_history_summary),
isChecked = searchHistory,
onCheckedChange = onToggleSearchHistory
)
PreferenceCategoryHeader(
title = stringResource(Res.string.settings_category_clear_data_title)
)
TextPreference(
title = stringResource(Res.string.metadata_cache_wipe_title),
summary = stringResource(Res.string.metadata_cache_wipe_summary),
onClick = onWipeMetadataCache
)
TextPreference(
title = stringResource(Res.string.clear_views_history_title),
summary = stringResource(Res.string.clear_views_history_summary),
onClick = { pending = PendingDelete.WATCH }
)
TextPreference(
title = stringResource(Res.string.clear_playback_states_title),
summary = stringResource(Res.string.clear_playback_states_summary),
onClick = { pending = PendingDelete.PLAYBACK_STATES }
)
TextPreference(
title = stringResource(Res.string.clear_search_history_title),
summary = stringResource(Res.string.clear_search_history_summary),
onClick = { pending = PendingDelete.SEARCH }
)
TextPreference(
title = stringResource(Res.string.clear_cookie_title),
summary = stringResource(Res.string.clear_cookie_summary),
onClick = onClearCookies,
enabled = hasRecaptchaCookies
)
}
}
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun HistorySettingsScreenPreview() {
HistorySettingsContent(
watchHistory = true,
playbackResume = true,
playbackStateLists = false,
searchHistory = true,
hasRecaptchaCookies = true,
onNavigateUp = {},
onToggleWatchHistory = {},
onTogglePlaybackResume = {},
onTogglePlaybackStateLists = {},
onToggleSearchHistory = {},
onWipeMetadataCache = {},
onDeleteWatchHistory = {},
onDeletePlaybackStates = {},
onDeleteSearchHistory = {},
onClearCookies = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun HistorySettingsScreenWatchHistoryOffPreview() {
HistorySettingsContent(
watchHistory = false,
playbackResume = true,
playbackStateLists = false,
searchHistory = true,
hasRecaptchaCookies = false,
onNavigateUp = {},
onToggleWatchHistory = {},
onTogglePlaybackResume = {},
onTogglePlaybackStateLists = {},
onToggleSearchHistory = {},
onWipeMetadataCache = {},
onDeleteWatchHistory = {},
onDeletePlaybackStates = {},
onDeleteSearchHistory = {},
onClearCookies = {}
)
}
@@ -0,0 +1,199 @@
package net.newpipe.app.screen.settings.home
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.BuildConfig
import net.newpipe.app.composable.TextPreference
import net.newpipe.app.composable.TopAppBar
import net.newpipe.app.navigation.Destination
import net.newpipe.app.navigation.Navigator
import net.newpipe.app.preview.ThemePreviewProvider
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.ic_backup
import newpipe.shared.generated.resources.ic_bug_report
import newpipe.shared.generated.resources.ic_file_download
import newpipe.shared.generated.resources.ic_history
import newpipe.shared.generated.resources.ic_language
import newpipe.shared.generated.resources.ic_newpipe_update
import newpipe.shared.generated.resources.ic_palette
import newpipe.shared.generated.resources.ic_play_arrow
import newpipe.shared.generated.resources.ic_rss_feed
import newpipe.shared.generated.resources.ic_settings
import newpipe.shared.generated.resources.ic_subscriptions
import newpipe.shared.generated.resources.ic_tv
import newpipe.shared.generated.resources.settings
import newpipe.shared.generated.resources.settings_category_backup_restore_title
import newpipe.shared.generated.resources.settings_category_content_title
import newpipe.shared.generated.resources.settings_category_debug_title
import newpipe.shared.generated.resources.settings_category_downloads_title
import newpipe.shared.generated.resources.settings_category_feed_title
import newpipe.shared.generated.resources.settings_category_history_title
import newpipe.shared.generated.resources.settings_category_language_title
import newpipe.shared.generated.resources.settings_category_look_and_feel_title
import newpipe.shared.generated.resources.settings_category_player_behavior_title
import newpipe.shared.generated.resources.settings_category_player_title
import newpipe.shared.generated.resources.settings_category_services_title
import newpipe.shared.generated.resources.settings_category_updates_title
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
/**
* One row on the Settings home screen.
*
* @param destination Where tapping the row navigates to. Used as theLazyColumn
* key — must be unique within the rendered list.
*/
internal data class SettingsHomeRow(
val title: StringResource,
val icon: DrawableResource,
val destination: Destination.Settings
)
@Composable
fun SettingsHomeScreen(navigator: Navigator = koinInject()) {
val rows = remember { defaultSettingsHomeRows(isDebugBuild = BuildConfig.DEBUG) }
SettingsHomeScreenContent(
rows = rows,
onNavigateUp = navigator::navigateUp,
onNavigateTo = navigator::navigateTo
)
}
@Composable
private fun SettingsHomeScreenContent(
rows: List<SettingsHomeRow>,
onNavigateUp: () -> Unit,
onNavigateTo: (Destination.Settings) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = stringResource(Res.string.settings),
onNavigateUp = onNavigateUp
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
items(items = rows, key = { it.destination.toString() }) { row
-> TextPreference(
title = stringResource(row.title),
icon = painterResource(row.icon),
onClick = { onNavigateTo(row.destination) }
)
}
}
}
}
/**
* Default rows shown on the Settings home screen.
*
* Updates is omitted in debug builds (in-app updates ship only viarelease
* APKs); Debug is added only in debug builds.
*/
private fun defaultSettingsHomeRows(isDebugBuild: Boolean):
List<SettingsHomeRow> = buildList {
add(SettingsHomeRow(
Res.string.settings_category_player_title,
Res.drawable.ic_play_arrow,
Destination.Settings.Player
))
add(SettingsHomeRow(
Res.string.settings_category_player_behavior_title,
Res.drawable.ic_settings,
Destination.Settings.Behaviour
))
add(SettingsHomeRow(
Res.string.settings_category_downloads_title,
Res.drawable.ic_file_download,
Destination.Settings.Download
))
add(SettingsHomeRow(
Res.string.settings_category_look_and_feel_title,
Res.drawable.ic_palette,
Destination.Settings.LookFeel
))
add(SettingsHomeRow(
Res.string.settings_category_history_title,
Res.drawable.ic_history,
Destination.Settings.HistoryCache
))
add(SettingsHomeRow(
Res.string.settings_category_content_title,
Res.drawable.ic_tv,
Destination.Settings.Content
))
add(SettingsHomeRow(
Res.string.settings_category_feed_title,
Res.drawable.ic_rss_feed,
Destination.Settings.Feed
))
add(SettingsHomeRow(
Res.string.settings_category_services_title,
Res.drawable.ic_subscriptions,
Destination.Settings.Services
))
add(SettingsHomeRow(
Res.string.settings_category_language_title,
Res.drawable.ic_language,
Destination.Settings.Language
))
add(SettingsHomeRow(
Res.string.settings_category_backup_restore_title,
Res.drawable.ic_backup,
Destination.Settings.BackupRestore
))
if (!isDebugBuild) {
add(SettingsHomeRow(
Res.string.settings_category_updates_title,
Res.drawable.ic_newpipe_update,
Destination.Settings.Updates
))
}
if (isDebugBuild) {
add(SettingsHomeRow(
Res.string.settings_category_debug_title,
Res.drawable.ic_bug_report,
Destination.Settings.Debug
))
}
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun SettingsHomeScreenPreview() {
SettingsHomeScreenContent(
rows = defaultSettingsHomeRows(isDebugBuild = false),
onNavigateUp = {},
onNavigateTo = {}
)
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun SettingsHomeScreenDebugBuildPreview() {
SettingsHomeScreenContent(
rows = defaultSettingsHomeRows(isDebugBuild = true),
onNavigateUp = {},
onNavigateTo = {}
)
}
@@ -0,0 +1,249 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.screen.settings.lookfeel
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.composable.ListPreference
import net.newpipe.app.composable.ListPreferenceOption
import net.newpipe.app.composable.SwitchPreference
import net.newpipe.app.composable.TextPreference
import net.newpipe.app.composable.TopAppBar
import net.newpipe.app.navigation.Navigator
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.viewmodel.settings.lookfeel.LIST_VIEW_AUTO
import net.newpipe.app.viewmodel.settings.lookfeel.LIST_VIEW_CARD
import net.newpipe.app.viewmodel.settings.lookfeel.LIST_VIEW_GRID
import net.newpipe.app.viewmodel.settings.lookfeel.LIST_VIEW_LIST
import net.newpipe.app.viewmodel.settings.lookfeel.LookFeelSettingsViewModel
import net.newpipe.app.viewmodel.settings.lookfeel.TABLET_AUTO
import net.newpipe.app.viewmodel.settings.lookfeel.TABLET_OFF
import net.newpipe.app.viewmodel.settings.lookfeel.TABLET_ON
import net.newpipe.app.viewmodel.settings.lookfeel.THEME_AUTO
import net.newpipe.app.viewmodel.settings.lookfeel.THEME_BLACK
import net.newpipe.app.viewmodel.settings.lookfeel.THEME_DARK
import net.newpipe.app.viewmodel.settings.lookfeel.THEME_LIGHT
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.caption_setting_description
import newpipe.shared.generated.resources.caption_setting_title
import newpipe.shared.generated.resources.list_view_mode_auto
import newpipe.shared.generated.resources.list_view_mode_card
import newpipe.shared.generated.resources.list_view_mode_grid
import newpipe.shared.generated.resources.list_view_mode_list
import newpipe.shared.generated.resources.list_view_mode_title
import newpipe.shared.generated.resources.main_tabs_position_summary
import newpipe.shared.generated.resources.main_tabs_position_title
import newpipe.shared.generated.resources.night_theme_available
import newpipe.shared.generated.resources.night_theme_summary
import newpipe.shared.generated.resources.night_theme_title
import newpipe.shared.generated.resources.settings_category_appearance_title
import newpipe.shared.generated.resources.show_hold_to_append_summary
import newpipe.shared.generated.resources.show_hold_to_append_title
import newpipe.shared.generated.resources.tablet_mode_auto
import newpipe.shared.generated.resources.tablet_mode_off
import newpipe.shared.generated.resources.tablet_mode_on
import newpipe.shared.generated.resources.tablet_mode_title
import newpipe.shared.generated.resources.theme_auto
import newpipe.shared.generated.resources.theme_black
import newpipe.shared.generated.resources.theme_dark
import newpipe.shared.generated.resources.theme_light
import newpipe.shared.generated.resources.theme_title
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun LookFeelSettingsScreen(
navigator: Navigator = koinInject(),
viewModel: LookFeelSettingsViewModel = koinViewModel()
) {
val theme by viewModel.theme.collectAsState()
val nightTheme by viewModel.nightTheme.collectAsState()
val showHoldToAppend by viewModel.showHoldToAppend.collectAsState()
val tabletMode by viewModel.tabletMode.collectAsState()
val listViewMode by viewModel.listViewMode.collectAsState()
val mainTabsPosition by viewModel.mainTabsPosition.collectAsState()
LookFeelSettingsContent(
theme = theme,
nightTheme = nightTheme,
showHoldToAppend = showHoldToAppend,
tabletMode = tabletMode,
listViewMode = listViewMode,
mainTabsPosition = mainTabsPosition,
onNavigateUp = navigator::navigateUp,
onSetTheme = viewModel::setTheme,
onSetNightTheme = viewModel::setNightTheme,
onToggleShowHoldToAppend = viewModel::toggleShowHoldToAppend,
onSetTabletMode = viewModel::setTabletMode,
onSetListViewMode = viewModel::setListViewMode,
onOpenCaptionSettings = viewModel::openCaptionSettings,
onToggleMainTabsPosition = viewModel::toggleMainTabsPosition
)
}
@Composable
private fun LookFeelSettingsContent(
theme: String,
nightTheme: String,
showHoldToAppend: Boolean,
tabletMode: String,
listViewMode: String,
mainTabsPosition: Boolean,
onNavigateUp: () -> Unit,
onSetTheme: (String) -> Unit,
onSetNightTheme: (String) -> Unit,
onToggleShowHoldToAppend: (Boolean) -> Unit,
onSetTabletMode: (String) -> Unit,
onSetListViewMode: (String) -> Unit,
onOpenCaptionSettings: () -> Unit,
onToggleMainTabsPosition: (Boolean) -> Unit
) {
val themeOptions = themeOptions()
val nightThemeOptions = nightThemeOptions()
val tabletOptions = tabletModeOptions()
val listViewOptions = listViewModeOptions()
val nightThemeEnabled = theme == THEME_AUTO
val autoLabel = stringResource(Res.string.theme_auto)
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = stringResource(Res.string.settings_category_appearance_title),
onNavigateUp = onNavigateUp
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
ListPreference(
title = stringResource(Res.string.theme_title),
options = themeOptions,
selectedValue = theme,
onValueChange = onSetTheme
)
ListPreference(
title = stringResource(Res.string.night_theme_title),
options = nightThemeOptions,
selectedValue = nightTheme,
onValueChange = onSetNightTheme,
enabled = nightThemeEnabled
)
if (!nightThemeEnabled) {
// Disabled-row summary parity with legacy.
TextPreference(
title = "",
summary = stringResource(Res.string.night_theme_available, autoLabel),
onClick = {},
enabled = false
)
} else {
TextPreference(
title = "",
summary = stringResource(Res.string.night_theme_summary),
onClick = {},
enabled = false
)
}
SwitchPreference(
title = stringResource(Res.string.show_hold_to_append_title),
summary = stringResource(Res.string.show_hold_to_append_summary),
isChecked = showHoldToAppend,
onCheckedChange = onToggleShowHoldToAppend
)
ListPreference(
title = stringResource(Res.string.tablet_mode_title),
options = tabletOptions,
selectedValue = tabletMode,
onValueChange = onSetTabletMode
)
ListPreference(
title = stringResource(Res.string.list_view_mode_title),
options = listViewOptions,
selectedValue = listViewMode,
onValueChange = onSetListViewMode
)
TextPreference(
title = stringResource(Res.string.caption_setting_title),
summary = stringResource(Res.string.caption_setting_description),
onClick = onOpenCaptionSettings
)
SwitchPreference(
title = stringResource(Res.string.main_tabs_position_title),
summary = stringResource(Res.string.main_tabs_position_summary),
isChecked = mainTabsPosition,
onCheckedChange = onToggleMainTabsPosition
)
}
}
}
@Composable
private fun themeOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(THEME_LIGHT, stringResource(Res.string.theme_light)),
ListPreferenceOption(THEME_DARK, stringResource(Res.string.theme_dark)),
ListPreferenceOption(THEME_BLACK, stringResource(Res.string.theme_black)),
ListPreferenceOption(THEME_AUTO, stringResource(Res.string.theme_auto))
)
@Composable
private fun nightThemeOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(THEME_DARK, stringResource(Res.string.theme_dark)),
ListPreferenceOption(THEME_BLACK, stringResource(Res.string.theme_black))
)
@Composable
private fun tabletModeOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(TABLET_AUTO, stringResource(Res.string.tablet_mode_auto)),
ListPreferenceOption(TABLET_ON, stringResource(Res.string.tablet_mode_on)),
ListPreferenceOption(TABLET_OFF, stringResource(Res.string.tablet_mode_off))
)
@Composable
private fun listViewModeOptions(): List<ListPreferenceOption> =
listOf(
ListPreferenceOption(LIST_VIEW_AUTO, stringResource(Res.string.list_view_mode_auto)),
ListPreferenceOption(LIST_VIEW_LIST, stringResource(Res.string.list_view_mode_list)),
ListPreferenceOption(LIST_VIEW_GRID, stringResource(Res.string.list_view_mode_grid)),
ListPreferenceOption(LIST_VIEW_CARD, stringResource(Res.string.list_view_mode_card))
)
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun LookFeelSettingsScreenPreview() {
LookFeelSettingsContent(
theme = THEME_AUTO,
nightTheme = THEME_DARK,
showHoldToAppend = true,
tabletMode = TABLET_AUTO,
listViewMode = LIST_VIEW_AUTO,
mainTabsPosition = false,
onNavigateUp = {},
onSetTheme = {},
onSetNightTheme = {},
onToggleShowHoldToAppend = {},
onSetTabletMode = {},
onSetListViewMode = {},
onOpenCaptionSettings = {},
onToggleMainTabsPosition = {}
)
}
@@ -1,2 +1,363 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.screen.settings.player
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.composable.ListPreference
import net.newpipe.app.composable.ListPreferenceOption
import net.newpipe.app.composable.PreferenceCategoryHeader
import net.newpipe.app.composable.SwitchPreference
import net.newpipe.app.composable.TextPreference
import net.newpipe.app.composable.TopAppBar
import net.newpipe.app.navigation.Destination
import net.newpipe.app.navigation.Navigator
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.viewmodel.settings.player.AUDIO_FORMAT_M4A
import net.newpipe.app.viewmodel.settings.player.AUDIO_FORMAT_OGG
import net.newpipe.app.viewmodel.settings.player.AUDIO_FORMAT_WEBM
import net.newpipe.app.viewmodel.settings.player.LIMIT_DATA_OFF
import net.newpipe.app.viewmodel.settings.player.LIMIT_DATA_WIFI_ONLY
import net.newpipe.app.viewmodel.settings.player.PlayerSettingsViewModel
import net.newpipe.app.viewmodel.settings.player.RESOLUTION_1080
import net.newpipe.app.viewmodel.settings.player.RESOLUTION_1080_60
import net.newpipe.app.viewmodel.settings.player.RESOLUTION_144
import net.newpipe.app.viewmodel.settings.player.RESOLUTION_1440
import net.newpipe.app.viewmodel.settings.player.RESOLUTION_2160
import net.newpipe.app.viewmodel.settings.player.RESOLUTION_240
import net.newpipe.app.viewmodel.settings.player.RESOLUTION_360
import net.newpipe.app.viewmodel.settings.player.RESOLUTION_480
import net.newpipe.app.viewmodel.settings.player.RESOLUTION_720
import net.newpipe.app.viewmodel.settings.player.RESOLUTION_720_60
import net.newpipe.app.viewmodel.settings.player.RESOLUTION_BEST
import net.newpipe.app.viewmodel.settings.player.THUMBNAIL_HIGH
import net.newpipe.app.viewmodel.settings.player.THUMBNAIL_LOW
import net.newpipe.app.viewmodel.settings.player.THUMBNAIL_OFF
import net.newpipe.app.viewmodel.settings.player.VIDEO_FORMAT_3GP
import net.newpipe.app.viewmodel.settings.player.VIDEO_FORMAT_MP4
import net.newpipe.app.viewmodel.settings.player.VIDEO_FORMAT_WEBM
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.audio_format_m4a
import newpipe.shared.generated.resources.audio_format_ogg
import newpipe.shared.generated.resources.audio_format_webm
import newpipe.shared.generated.resources.best_resolution
import newpipe.shared.generated.resources.default_audio_format_title
import newpipe.shared.generated.resources.default_popup_resolution_title
import newpipe.shared.generated.resources.default_resolution_title
import newpipe.shared.generated.resources.default_video_format_title
import newpipe.shared.generated.resources.limit_data_usage_none
import newpipe.shared.generated.resources.limit_data_usage_wifi_only
import newpipe.shared.generated.resources.limit_mobile_data_usage_title
import newpipe.shared.generated.resources.prefer_descriptive_audio_summary
import newpipe.shared.generated.resources.prefer_descriptive_audio_title
import newpipe.shared.generated.resources.prefer_original_audio_summary
import newpipe.shared.generated.resources.prefer_original_audio_title
import newpipe.shared.generated.resources.resolution_1080p
import newpipe.shared.generated.resources.resolution_1080p60
import newpipe.shared.generated.resources.resolution_1440p
import newpipe.shared.generated.resources.resolution_144p
import newpipe.shared.generated.resources.resolution_2160p
import newpipe.shared.generated.resources.resolution_240p
import newpipe.shared.generated.resources.resolution_360p
import newpipe.shared.generated.resources.resolution_480p
import newpipe.shared.generated.resources.resolution_720p
import newpipe.shared.generated.resources.resolution_720p60
import newpipe.shared.generated.resources.seekbar_preview_thumbnail_high
import newpipe.shared.generated.resources.seekbar_preview_thumbnail_low
import newpipe.shared.generated.resources.seekbar_preview_thumbnail_off
import newpipe.shared.generated.resources.seekbar_preview_thumbnail_title
import newpipe.shared.generated.resources.settings_category_exoplayer_summary
import newpipe.shared.generated.resources.settings_category_exoplayer_title
import newpipe.shared.generated.resources.settings_category_player_title
import newpipe.shared.generated.resources.settings_category_video_audio_title
import newpipe.shared.generated.resources.show_higher_resolutions_summary
import newpipe.shared.generated.resources.show_higher_resolutions_title
import newpipe.shared.generated.resources.show_play_with_kodi_summary
import newpipe.shared.generated.resources.show_play_with_kodi_title
import newpipe.shared.generated.resources.use_external_audio_player_title
import newpipe.shared.generated.resources.use_external_video_player_summary
import newpipe.shared.generated.resources.use_external_video_player_title
import newpipe.shared.generated.resources.video_format_3gp
import newpipe.shared.generated.resources.video_format_mp4
import newpipe.shared.generated.resources.video_format_webm
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun PlayerSettingsScreen(
navigator: Navigator = koinInject(),
viewModel: PlayerSettingsViewModel = koinViewModel()
) {
val defaultResolution by viewModel.defaultResolution.collectAsState()
val defaultPopupResolution by viewModel.defaultPopupResolution.collectAsState()
val limitMobileDataUsage by viewModel.limitMobileDataUsage.collectAsState()
val showHigherResolutions by viewModel.showHigherResolutions.collectAsState()
val defaultVideoFormat by viewModel.defaultVideoFormat.collectAsState()
val defaultAudioFormat by viewModel.defaultAudioFormat.collectAsState()
val preferOriginalAudio by viewModel.preferOriginalAudio.collectAsState()
val preferDescriptiveAudio by viewModel.preferDescriptiveAudio.collectAsState()
val useExternalVideoPlayer by viewModel.useExternalVideoPlayer.collectAsState()
val useExternalAudioPlayer by viewModel.useExternalAudioPlayer.collectAsState()
val showPlayWithKodi by viewModel.showPlayWithKodi.collectAsState()
val seekbarThumbnail by viewModel.seekbarThumbnail.collectAsState()
PlayerSettingsContent(
defaultResolution = defaultResolution,
defaultPopupResolution = defaultPopupResolution,
limitMobileDataUsage = limitMobileDataUsage,
showHigherResolutions = showHigherResolutions,
defaultVideoFormat = defaultVideoFormat,
defaultAudioFormat = defaultAudioFormat,
preferOriginalAudio = preferOriginalAudio,
preferDescriptiveAudio = preferDescriptiveAudio,
useExternalVideoPlayer = useExternalVideoPlayer,
useExternalAudioPlayer = useExternalAudioPlayer,
showPlayWithKodi = showPlayWithKodi,
seekbarThumbnail = seekbarThumbnail,
onNavigateUp = navigator::navigateUp,
onSetDefaultResolution = viewModel::setDefaultResolution,
onSetDefaultPopupResolution = viewModel::setDefaultPopupResolution,
onSetLimitMobileDataUsage = viewModel::setLimitMobileDataUsage,
onToggleShowHigherResolutions = viewModel::toggleShowHigherResolutions,
onSetDefaultVideoFormat = viewModel::setDefaultVideoFormat,
onSetDefaultAudioFormat = viewModel::setDefaultAudioFormat,
onTogglePreferOriginalAudio = viewModel::togglePreferOriginalAudio,
onTogglePreferDescriptiveAudio = viewModel::togglePreferDescriptiveAudio,
onExoPlayerSettings = { navigator.navigateTo(Destination.Settings.ExoPlayer) },
onToggleUseExternalVideoPlayer = viewModel::toggleUseExternalVideoPlayer,
onToggleUseExternalAudioPlayer = viewModel::toggleUseExternalAudioPlayer,
onToggleShowPlayWithKodi = viewModel::toggleShowPlayWithKodi,
onSetSeekbarThumbnail = viewModel::setSeekbarThumbnail
)
}
@Composable
private fun PlayerSettingsContent(
defaultResolution: String,
defaultPopupResolution: String,
limitMobileDataUsage: String,
showHigherResolutions: Boolean,
defaultVideoFormat: String,
defaultAudioFormat: String,
preferOriginalAudio: Boolean,
preferDescriptiveAudio: Boolean,
useExternalVideoPlayer: Boolean,
useExternalAudioPlayer: Boolean,
showPlayWithKodi: Boolean,
seekbarThumbnail: String,
onNavigateUp: () -> Unit,
onSetDefaultResolution: (String) -> Unit,
onSetDefaultPopupResolution: (String) -> Unit,
onSetLimitMobileDataUsage: (String) -> Unit,
onToggleShowHigherResolutions: (Boolean) -> Unit,
onSetDefaultVideoFormat: (String) -> Unit,
onSetDefaultAudioFormat: (String) -> Unit,
onTogglePreferOriginalAudio: (Boolean) -> Unit,
onTogglePreferDescriptiveAudio: (Boolean) -> Unit,
onExoPlayerSettings: () -> Unit,
onToggleUseExternalVideoPlayer: (Boolean) -> Unit,
onToggleUseExternalAudioPlayer: (Boolean) -> Unit,
onToggleShowPlayWithKodi: (Boolean) -> Unit,
onSetSeekbarThumbnail: (String) -> Unit
) {
val resolutionOptions = resolutionOptions(showHigherResolutions)
val limitDataOptions = limitDataOptions()
val videoFormatOptions = videoFormatOptions()
val audioFormatOptions = audioFormatOptions()
val thumbnailOptions = thumbnailOptions()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = stringResource(Res.string.settings_category_video_audio_title),
onNavigateUp = onNavigateUp
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
ListPreference(
title = stringResource(Res.string.default_resolution_title),
options = resolutionOptions,
selectedValue = defaultResolution,
onValueChange = onSetDefaultResolution
)
ListPreference(
title = stringResource(Res.string.default_popup_resolution_title),
options = resolutionOptions,
selectedValue = defaultPopupResolution,
onValueChange = onSetDefaultPopupResolution
)
ListPreference(
title = stringResource(Res.string.limit_mobile_data_usage_title),
options = limitDataOptions,
selectedValue = limitMobileDataUsage,
onValueChange = onSetLimitMobileDataUsage
)
SwitchPreference(
title = stringResource(Res.string.show_higher_resolutions_title),
summary = stringResource(Res.string.show_higher_resolutions_summary),
isChecked = showHigherResolutions,
onCheckedChange = onToggleShowHigherResolutions
)
ListPreference(
title = stringResource(Res.string.default_video_format_title),
options = videoFormatOptions,
selectedValue = defaultVideoFormat,
onValueChange = onSetDefaultVideoFormat
)
ListPreference(
title = stringResource(Res.string.default_audio_format_title),
options = audioFormatOptions,
selectedValue = defaultAudioFormat,
onValueChange = onSetDefaultAudioFormat
)
SwitchPreference(
title = stringResource(Res.string.prefer_original_audio_title),
summary = stringResource(Res.string.prefer_original_audio_summary),
isChecked = preferOriginalAudio,
onCheckedChange = onTogglePreferOriginalAudio
)
SwitchPreference(
title = stringResource(Res.string.prefer_descriptive_audio_title),
summary = stringResource(Res.string.prefer_descriptive_audio_summary),
isChecked = preferDescriptiveAudio,
onCheckedChange = onTogglePreferDescriptiveAudio
)
TextPreference(
title = stringResource(Res.string.settings_category_exoplayer_title),
summary = stringResource(Res.string.settings_category_exoplayer_summary),
onClick = onExoPlayerSettings
)
PreferenceCategoryHeader(
title = stringResource(Res.string.settings_category_player_title)
)
SwitchPreference(
title = stringResource(Res.string.use_external_video_player_title),
summary = stringResource(Res.string.use_external_video_player_summary),
isChecked = useExternalVideoPlayer,
onCheckedChange = onToggleUseExternalVideoPlayer
)
SwitchPreference(
title = stringResource(Res.string.use_external_audio_player_title),
isChecked = useExternalAudioPlayer,
onCheckedChange = onToggleUseExternalAudioPlayer
)
SwitchPreference(
title = stringResource(Res.string.show_play_with_kodi_title),
summary = stringResource(Res.string.show_play_with_kodi_summary),
isChecked = showPlayWithKodi,
onCheckedChange = onToggleShowPlayWithKodi
)
ListPreference(
title = stringResource(Res.string.seekbar_preview_thumbnail_title),
options = thumbnailOptions,
selectedValue = seekbarThumbnail,
onValueChange = onSetSeekbarThumbnail
)
}
}
}
@Composable
private fun resolutionOptions(showHigh: Boolean): List<ListPreferenceOption> {
val base = listOf(
ListPreferenceOption(RESOLUTION_BEST, stringResource(Res.string.best_resolution)),
ListPreferenceOption(RESOLUTION_1080_60, stringResource(Res.string.resolution_1080p60)),
ListPreferenceOption(RESOLUTION_1080, stringResource(Res.string.resolution_1080p)),
ListPreferenceOption(RESOLUTION_720_60, stringResource(Res.string.resolution_720p60)),
ListPreferenceOption(RESOLUTION_720, stringResource(Res.string.resolution_720p)),
ListPreferenceOption(RESOLUTION_480, stringResource(Res.string.resolution_480p)),
ListPreferenceOption(RESOLUTION_360, stringResource(Res.string.resolution_360p)),
ListPreferenceOption(RESOLUTION_240, stringResource(Res.string.resolution_240p)),
ListPreferenceOption(RESOLUTION_144, stringResource(Res.string.resolution_144p))
)
val high = listOf(
ListPreferenceOption(RESOLUTION_2160, stringResource(Res.string.resolution_2160p)),
ListPreferenceOption(RESOLUTION_1440, stringResource(Res.string.resolution_1440p))
)
return if (showHigh) listOf(base.first()) + high + base.drop(1) else base
}
@Composable
private fun limitDataOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(LIMIT_DATA_OFF, stringResource(Res.string.limit_data_usage_none)),
ListPreferenceOption(LIMIT_DATA_WIFI_ONLY, stringResource(Res.string.limit_data_usage_wifi_only))
)
@Composable
private fun videoFormatOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(VIDEO_FORMAT_MP4, stringResource(Res.string.video_format_mp4)),
ListPreferenceOption(VIDEO_FORMAT_WEBM, stringResource(Res.string.video_format_webm)),
ListPreferenceOption(VIDEO_FORMAT_3GP, stringResource(Res.string.video_format_3gp))
)
@Composable
private fun audioFormatOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(AUDIO_FORMAT_M4A, stringResource(Res.string.audio_format_m4a)),
ListPreferenceOption(AUDIO_FORMAT_WEBM, stringResource(Res.string.audio_format_webm)),
ListPreferenceOption(AUDIO_FORMAT_OGG, stringResource(Res.string.audio_format_ogg))
)
@Composable
private fun thumbnailOptions(): List<ListPreferenceOption> = listOf(
ListPreferenceOption(THUMBNAIL_HIGH, stringResource(Res.string.seekbar_preview_thumbnail_high)),
ListPreferenceOption(THUMBNAIL_LOW, stringResource(Res.string.seekbar_preview_thumbnail_low)),
ListPreferenceOption(THUMBNAIL_OFF, stringResource(Res.string.seekbar_preview_thumbnail_off))
)
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun PlayerSettingsScreenPreview() {
PlayerSettingsContent(
defaultResolution = RESOLUTION_BEST,
defaultPopupResolution = RESOLUTION_720,
limitMobileDataUsage = LIMIT_DATA_OFF,
showHigherResolutions = false,
defaultVideoFormat = VIDEO_FORMAT_MP4,
defaultAudioFormat = AUDIO_FORMAT_M4A,
preferOriginalAudio = true,
preferDescriptiveAudio = false,
useExternalVideoPlayer = false,
useExternalAudioPlayer = false,
showPlayWithKodi = false,
seekbarThumbnail = THUMBNAIL_HIGH,
onNavigateUp = {},
onSetDefaultResolution = {},
onSetDefaultPopupResolution = {},
onSetLimitMobileDataUsage = {},
onToggleShowHigherResolutions = {},
onSetDefaultVideoFormat = {},
onSetDefaultAudioFormat = {},
onTogglePreferOriginalAudio = {},
onTogglePreferDescriptiveAudio = {},
onExoPlayerSettings = {},
onToggleUseExternalVideoPlayer = {},
onToggleUseExternalAudioPlayer = {},
onToggleShowPlayWithKodi = {},
onSetSeekbarThumbnail = {}
)
}
@@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.screen.settings.updates
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import net.newpipe.app.composable.SwitchPreference
import net.newpipe.app.composable.TextPreference
import net.newpipe.app.composable.TopAppBar
import net.newpipe.app.navigation.Navigator
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.viewmodel.settings.updates.UpdatesSettingsViewModel
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.check_for_updates
import newpipe.shared.generated.resources.manual_update_description
import newpipe.shared.generated.resources.settings_category_updates_title
import newpipe.shared.generated.resources.updates_setting_description
import newpipe.shared.generated.resources.updates_setting_title
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun UpdatesSettingsScreen(
navigator: Navigator = koinInject(),
viewModel: UpdatesSettingsViewModel = koinViewModel()
) {
val updateApp by viewModel.updateApp.collectAsState()
UpdatesSettingsContent(
updateApp = updateApp,
onNavigateUp = navigator::navigateUp,
onToggleUpdateApp = viewModel::toggleUpdateApp,
onManualCheck = viewModel::runManualCheck
)
}
@Composable
private fun UpdatesSettingsContent(
updateApp: Boolean,
onNavigateUp: () -> Unit,
onToggleUpdateApp: (Boolean) -> Unit,
onManualCheck: () -> Unit
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = stringResource(Res.string.settings_category_updates_title),
onNavigateUp = onNavigateUp
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
SwitchPreference(
title = stringResource(Res.string.updates_setting_title),
summary = stringResource(Res.string.updates_setting_description),
isChecked = updateApp,
onCheckedChange = onToggleUpdateApp
)
TextPreference(
title = stringResource(Res.string.check_for_updates),
summary = stringResource(Res.string.manual_update_description),
onClick = onManualCheck
)
}
}
}
@Suppress("UnusedPrivateMember")
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun UpdatesSettingsScreenPreview() {
UpdatesSettingsContent(
updateApp = false,
onNavigateUp = {},
onToggleUpdateApp = {},
onManualCheck = {}
)
}
@@ -22,3 +22,5 @@ val iconHDPI = 72.dp
val iconXHDPI = 96.dp
val iconXXHDPI = 144.dp
val iconXXXHDPI = 192.dp
val preferenceMinHeight = 48.dp
@@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import com.russhwolf.settings.ExperimentalSettingsApi
/**
* Encapsulates a boolean preference backed by the multiplatform
@@ -21,6 +22,7 @@ string-resource key).
* @param scope Scope to keep the underlying flow alive (typically the
ViewModel scope).
*/
@OptIn(ExperimentalSettingsApi::class)
internal class BooleanPreference(
private val key: String,
private val defaultValue: Boolean,
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.viewmodel.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.russhwolf.settings.ObservableSettings
import org.koin.core.annotation.KoinViewModel
private const val SETTINGS_LAYOUT_REDESIGN_KEY = "settings_layout_redesign_key"
@KoinViewModel
class SettingsViewModel(
settings: ObservableSettings
) : ViewModel() {
private val settingsLayoutRedesignPref =
BooleanPreference(
key = SETTINGS_LAYOUT_REDESIGN_KEY,
defaultValue = false,
settings = settings,
scope = viewModelScope
)
val settingsLayoutRedesign = settingsLayoutRedesignPref.state
fun toggleSettingsLayoutRedesign(newValue: Boolean) =
settingsLayoutRedesignPref.toggle(newValue)
}
@@ -7,6 +7,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import com.russhwolf.settings.ExperimentalSettingsApi
/**
* String-valued analogue of [BooleanPreference]. Backs [ListPreference]
* composables — anything that stores a small set of opaque string values.
@@ -16,6 +18,7 @@ import kotlinx.coroutines.flow.stateIn
* @param settings The shared, observable key-value store.
* @param scope Scope that keeps the underlying flow alive (typically viewModelScope).
*/
@OptIn(ExperimentalSettingsApi::class)
internal class StringPreference(
private val key: String,
private val defaultValue: String,
@@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.viewmodel.settings
import com.russhwolf.settings.ObservableSettings
import com.russhwolf.settings.coroutines.getStringFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import com.russhwolf.settings.ExperimentalSettingsApi
/**
* Stores a `Set<String>` as a comma-delimited [String] under [key].
*
* Used by multi-select preferences. NOTE: this does **not** share format with
* Android's `MultiSelectListPreference` (which uses `getStringSet`). Until the
* legacy XML screens are retired, multi-select prefs migrated to Compose will
* appear "reset" to default when viewed in the legacy UI and vice-versa.
*
* @param defaultValue values applied when the key is missing.
*/
@OptIn(ExperimentalSettingsApi::class)
internal class StringSetPreference(
private val key: String,
private val defaultValue: Set<String>,
private val settings: ObservableSettings,
scope: CoroutineScope
) {
val state: StateFlow<Set<String>> = settings
.getStringFlow(key, defaultValue.joinToString(","))
.map { it.split(",").filter { piece -> piece.isNotEmpty()
}.toSet() }
.stateIn(
scope = scope,
started = SharingStarted.Companion.Eagerly,
initialValue = decode(settings.getString(key,
defaultValue.joinToString(",")))
)
fun set(newValue: Set<String>) {
settings.putString(key, newValue.joinToString(","))
}
private fun decode(raw: String): Set<String> =
raw.split(",").filter { it.isNotEmpty() }.toSet()
}
@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.viewmodel.settings.backuprestore
import androidx.lifecycle.ViewModel
import net.newpipe.app.platform.BackupRestoreActions
import org.koin.core.annotation.KoinViewModel
@KoinViewModel
class BackupRestoreSettingsViewModel(
private val actions: BackupRestoreActions
) : ViewModel() {
fun importDatabase() = actions.importDatabase()
fun exportDatabase() = actions.exportDatabase()
fun resetAllSettings() = actions.resetAllSettings()
fun importSubscriptions() = actions.importSubscriptions()
fun exportSubscriptions() = actions.exportSubscriptions()
}
@@ -0,0 +1,189 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.viewmodel.settings.content
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.russhwolf.settings.ObservableSettings
import kotlinx.coroutines.flow.StateFlow
import net.newpipe.app.platform.ContentActions
import net.newpipe.app.viewmodel.settings.BooleanPreference
import net.newpipe.app.viewmodel.settings.StringPreference
import net.newpipe.app.viewmodel.settings.StringSetPreference
import org.koin.core.annotation.KoinViewModel
// Keys mirror app/src/main/res/values/settings_keys.xml verbatim.
private const val APP_LANGUAGE_KEY = "app_language_key"
private const val CONTENT_LANGUAGE_KEY = "content_language"
private const val CONTENT_COUNTRY_KEY = "content_country"
private const val SHOW_CHANNEL_TABS_KEY = "channel_tabs"
private const val SHOW_AGE_RESTRICTED_KEY = "show_age_restricted_content"
private const val YOUTUBE_RESTRICTED_MODE_KEY =
"youtube_restricted_mode_enabled"
private const val SHOW_SEARCH_SUGGESTIONS_KEY = "show_search_suggestions"
private const val IMAGE_QUALITY_KEY = "image_quality_key"
private const val SHOW_COMMENTS_KEY = "show_comments"
private const val SHOW_NEXT_VIDEO_KEY = "show_next_video"
private const val SHOW_DESCRIPTION_KEY = "show_description"
private const val SHOW_META_INFO_KEY = "show_meta_info"
private const val FEED_UPDATE_THRESHOLD_KEY = "feed_update_threshold_key"
private const val FEED_USE_DEDICATED_FETCH_KEY =
"feed_use_dedicated_fetch_method"
private const val FEED_FETCH_CHANNEL_TABS_KEY = "feed_fetch_channel_tabs"
// Default localization
internal const val LOCALIZATION_SYSTEM = "system"
// Image quality values (match settings_keys.xml verbatim)
internal const val IMAGE_QUALITY_LOW = "image_quality_low"
internal const val IMAGE_QUALITY_MEDIUM = "image_quality_medium"
internal const val IMAGE_QUALITY_HIGH = "image_quality_high"
// Channel tab values
internal const val CHANNEL_TAB_VIDEOS = "show_channel_tabs_videos"
internal const val CHANNEL_TAB_TRACKS = "show_channel_tabs_tracks"
internal const val CHANNEL_TAB_SHORTS = "show_channel_tabs_shorts"
internal const val CHANNEL_TAB_LIVESTREAMS =
"show_channel_tabs_livestreams"
internal const val CHANNEL_TAB_CHANNELS = "show_channel_tabs_channels"
internal const val CHANNEL_TAB_PLAYLISTS = "show_channel_tabs_playlists"
internal const val CHANNEL_TAB_ALBUMS = "show_channel_tabs_albums"
internal const val CHANNEL_TAB_LIKES = "show_channel_tabs_likes"
internal const val CHANNEL_TAB_ABOUT = "show_channel_tabs_about"
private val DEFAULT_CHANNEL_TABS = setOf(
CHANNEL_TAB_VIDEOS, CHANNEL_TAB_TRACKS, CHANNEL_TAB_SHORTS,
CHANNEL_TAB_LIVESTREAMS, CHANNEL_TAB_CHANNELS, CHANNEL_TAB_PLAYLISTS,
CHANNEL_TAB_ALBUMS, CHANNEL_TAB_LIKES, CHANNEL_TAB_ABOUT
)
// Search-suggestion values
internal const val SUGGESTIONS_LOCAL = "show_local_search_suggestions"
internal const val SUGGESTIONS_REMOTE = "show_remote_search_suggestions"
private val DEFAULT_SUGGESTIONS = setOf(SUGGESTIONS_LOCAL,
SUGGESTIONS_REMOTE)
// Feed update threshold values (seconds, stored as Strings)
internal const val THRESHOLD_IMMEDIATE = "0"
internal const val THRESHOLD_5_MIN = "300"
internal const val THRESHOLD_15_MIN = "900"
internal const val THRESHOLD_1_HOUR = "3600"
internal const val THRESHOLD_6_HOURS = "21600"
internal const val THRESHOLD_12_HOURS = "43200"
internal const val THRESHOLD_1_DAY = "86400"
// Feed fetch channel-tab values
internal const val FETCH_TAB_VIDEOS = "fetch_channel_tabs_videos"
internal const val FETCH_TAB_TRACKS = "fetch_channel_tabs_tracks"
internal const val FETCH_TAB_SHORTS = "fetch_channel_tabs_shorts"
internal const val FETCH_TAB_LIVESTREAMS = "fetch_channel_tabs_livestreams"
internal const val FETCH_TAB_LIKES = "fetch_channel_tabs_likes"
private val DEFAULT_FETCH_TABS = setOf(
FETCH_TAB_VIDEOS, FETCH_TAB_TRACKS, FETCH_TAB_SHORTS, FETCH_TAB_LIVESTREAMS, FETCH_TAB_LIKES
)
@KoinViewModel
class ContentSettingsViewModel(
settings: ObservableSettings,
private val contentActions: ContentActions
) : ViewModel() {
// ListPreferences
private val appLanguagePref = StringPreference(
APP_LANGUAGE_KEY, LOCALIZATION_SYSTEM, settings, viewModelScope
)
private val contentLanguagePref = StringPreference(
CONTENT_LANGUAGE_KEY, LOCALIZATION_SYSTEM, settings, viewModelScope
)
private val contentCountryPref = StringPreference(
CONTENT_COUNTRY_KEY, LOCALIZATION_SYSTEM, settings, viewModelScope
)
private val imageQualityPref = StringPreference(
IMAGE_QUALITY_KEY, IMAGE_QUALITY_MEDIUM, settings, viewModelScope
)
private val feedUpdateThresholdPref = StringPreference(
FEED_UPDATE_THRESHOLD_KEY, THRESHOLD_5_MIN, settings, viewModelScope
)
val appLanguage: StateFlow<String> = appLanguagePref.state
val contentLanguage: StateFlow<String> = contentLanguagePref.state
val contentCountry: StateFlow<String> = contentCountryPref.state
val imageQuality: StateFlow<String> = imageQualityPref.state
val feedUpdateThreshold: StateFlow<String> = feedUpdateThresholdPref.state
fun setAppLanguage(value: String) {
appLanguagePref.set(value)
contentActions.onAppLanguageChanged()
}
fun setContentLanguage(value: String) = contentLanguagePref.set(value)
fun setContentCountry(value: String) = contentCountryPref.set(value)
fun setImageQuality(value: String) = imageQualityPref.set(value)
fun setFeedUpdateThreshold(value: String) = feedUpdateThresholdPref.set(value)
// ── Switches
private val showAgeRestrictedPref = BooleanPreference(
SHOW_AGE_RESTRICTED_KEY, false, settings, viewModelScope
)
private val youtubeRestrictedModePref = BooleanPreference(
YOUTUBE_RESTRICTED_MODE_KEY, false, settings, viewModelScope
)
private val showCommentsPref = BooleanPreference(
SHOW_COMMENTS_KEY, true, settings, viewModelScope
)
private val showNextVideoPref = BooleanPreference(
SHOW_NEXT_VIDEO_KEY, true, settings, viewModelScope
)
private val showDescriptionPref = BooleanPreference(
SHOW_DESCRIPTION_KEY, true, settings, viewModelScope
)
private val showMetaInfoPref = BooleanPreference(
SHOW_META_INFO_KEY, true, settings, viewModelScope
)
private val feedUseDedicatedFetchPref = BooleanPreference(
FEED_USE_DEDICATED_FETCH_KEY, false, settings, viewModelScope
)
val showAgeRestricted: StateFlow<Boolean> = showAgeRestrictedPref.state
val youtubeRestrictedMode: StateFlow<Boolean> = youtubeRestrictedModePref.state
val showComments: StateFlow<Boolean> = showCommentsPref.state
val showNextVideo: StateFlow<Boolean> = showNextVideoPref.state
val showDescription: StateFlow<Boolean> = showDescriptionPref.state
val showMetaInfo: StateFlow<Boolean> = showMetaInfoPref.state
val feedUseDedicatedFetch: StateFlow<Boolean> = feedUseDedicatedFetchPref.state
fun toggleShowAgeRestricted(v: Boolean) = showAgeRestrictedPref.toggle(v)
fun toggleYoutubeRestrictedMode(v: Boolean) = youtubeRestrictedModePref.toggle(v)
fun toggleShowComments(v: Boolean) = showCommentsPref.toggle(v)
fun toggleShowNextVideo(v: Boolean) = showNextVideoPref.toggle(v)
fun toggleShowDescription(v: Boolean) = showDescriptionPref.toggle(v)
fun toggleShowMetaInfo(v: Boolean) = showMetaInfoPref.toggle(v)
fun toggleFeedUseDedicatedFetch(v: Boolean) = feedUseDedicatedFetchPref.toggle(v)
// MultiSelect
private val showChannelTabsPref = StringSetPreference(
SHOW_CHANNEL_TABS_KEY, DEFAULT_CHANNEL_TABS, settings, viewModelScope
)
private val showSearchSuggestionsPref = StringSetPreference(
SHOW_SEARCH_SUGGESTIONS_KEY, DEFAULT_SUGGESTIONS, settings, viewModelScope
)
private val feedFetchChannelTabsPref = StringSetPreference(
FEED_FETCH_CHANNEL_TABS_KEY, DEFAULT_FETCH_TABS, settings, viewModelScope
)
val showChannelTabs: StateFlow<Set<String>> = showChannelTabsPref.state
val showSearchSuggestions: StateFlow<Set<String>> = showSearchSuggestionsPref.state
val feedFetchChannelTabs: StateFlow<Set<String>> = feedFetchChannelTabsPref.state
fun setShowChannelTabs(values: Set<String>) = showChannelTabsPref.set(values)
fun setShowSearchSuggestions(values: Set<String>) = showSearchSuggestionsPref.set(values)
fun setFeedFetchChannelTabs(values: Set<String>) = feedFetchChannelTabsPref.set(values)
// Sub-screen launchers
fun openMainPageTabs() = contentActions.openMainPageTabsChooser()
fun openPeertubeInstances() = contentActions.openPeertubeInstanceList()
}
@@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.viewmodel.settings.debug
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.russhwolf.settings.ObservableSettings
import net.newpipe.app.platform.DebugActions
import net.newpipe.app.viewmodel.settings.BooleanPreference
import org.koin.core.annotation.KoinViewModel
private const val ALLOW_HEAP_DUMPING_KEY = "allow_heap_dumping_key"
private const val ALLOW_DISPOSED_EXCEPTIONS_KEY = "allow_disposed_exceptions_key"
private const val SHOW_ORIGINAL_TIME_AGO_KEY = "show_original_time_ago_key"
private const val SHOW_CRASH_THE_PLAYER_KEY = "show_crash_the_player_key"
@KoinViewModel
class DebugSettingsViewModel(
settings: ObservableSettings,
private val debugActions: DebugActions
) : ViewModel() {
private val allowHeapDumpingPref = BooleanPreference(
ALLOW_HEAP_DUMPING_KEY, false, settings, viewModelScope
)
private val allowDisposedExceptionsPref = BooleanPreference(
ALLOW_DISPOSED_EXCEPTIONS_KEY, false, settings, viewModelScope
)
private val showOriginalTimeAgoPref = BooleanPreference(
SHOW_ORIGINAL_TIME_AGO_KEY, false, settings, viewModelScope
)
private val showCrashThePlayerPref = BooleanPreference(
SHOW_CRASH_THE_PLAYER_KEY, false, settings, viewModelScope
)
val isLeakCanaryAvailable: Boolean = debugActions.isLeakCanaryAvailable()
val allowHeapDumping = allowHeapDumpingPref.state
val allowDisposedExceptions = allowDisposedExceptionsPref.state
val showOriginalTimeAgo = showOriginalTimeAgoPref.state
val showCrashThePlayer = showCrashThePlayerPref.state
fun toggleAllowHeapDumping(newValue: Boolean) = allowHeapDumpingPref.toggle(newValue)
fun toggleAllowDisposedExceptions(newValue: Boolean) = allowDisposedExceptionsPref.toggle(newValue)
fun toggleShowOriginalTimeAgo(newValue: Boolean) = showOriginalTimeAgoPref.toggle(newValue)
fun toggleShowCrashThePlayer(newValue: Boolean) = showCrashThePlayerPref.toggle(newValue)
fun crashTheApp() = debugActions.crashTheApp()
fun showErrorSnackbar() = debugActions.showErrorSnackbar()
fun createErrorNotification() = debugActions.createErrorNotification()
fun checkNewStreams() = debugActions.checkNewStreams()
fun showMemoryLeaks() = debugActions.showMemoryLeaks()
}
@@ -8,11 +8,10 @@ package net.newpipe.app.viewmodel.settings.download
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.russhwolf.settings.ObservableSettings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import net.newpipe.app.platform.DownloadActions
import net.newpipe.app.screen.settings.BooleanPreference
import net.newpipe.app.viewmodel.settings.BooleanPreference
import net.newpipe.app.viewmodel.settings.StringPreference
import org.koin.core.annotation.KoinViewModel
// Keys mirror app/src/main/res/values/settings_keys.xml — keep in sync.
@@ -23,31 +22,31 @@ private const val DOWNLOAD_PATH_AUDIO_KEY = "download_path_audio_key"
@KoinViewModel
class DownloadSettingsViewModel(
private val settings: ObservableSettings,
settings: ObservableSettings,
private val actions: DownloadActions
) : ViewModel() {
private val askPref = BooleanPreference(DOWNLOADS_STORAGE_ASK_KEY, false, settings, viewModelScope)
private val useSafPref = BooleanPreference(STORAGE_USE_SAF_KEY, true, settings, viewModelScope)
private val askPref = BooleanPreference(
DOWNLOADS_STORAGE_ASK_KEY, false, settings, viewModelScope
)
private val useSafPref = BooleanPreference(
STORAGE_USE_SAF_KEY, true, settings, viewModelScope
)
private val videoPathPref = StringPreference(
DOWNLOAD_PATH_VIDEO_KEY, "", settings, viewModelScope
)
private val audioPathPref = StringPreference(
DOWNLOAD_PATH_AUDIO_KEY, "", settings, viewModelScope
)
private val _videoPath = MutableStateFlow(settings.getStringOrNull(DOWNLOAD_PATH_VIDEO_KEY).orEmpty())
private val _audioPath = MutableStateFlow(settings.getStringOrNull(DOWNLOAD_PATH_AUDIO_KEY).orEmpty())
val storageAsk = askPref.state
val useSaf = useSafPref.state
val videoPath: StateFlow<String> = _videoPath.asStateFlow()
val audioPath: StateFlow<String> = _audioPath.asStateFlow()
val storageAsk: StateFlow<Boolean> = askPref.state
val useSaf: StateFlow<Boolean> = useSafPref.state
val videoPath: StateFlow<String> = videoPathPref.state
val audioPath: StateFlow<String> = audioPathPref.state
fun toggleStorageAsk(v: Boolean) = askPref.toggle(v)
fun toggleUseSaf(v: Boolean) = useSafPref.toggle(v)
fun pickVideoPath() = actions.pickDirectory { uri ->
settings.putString(DOWNLOAD_PATH_VIDEO_KEY, uri)
_videoPath.value = uri
}
fun pickAudioPath() = actions.pickDirectory { uri ->
settings.putString(DOWNLOAD_PATH_AUDIO_KEY, uri)
_audioPath.value = uri
}
fun pickVideoPath() = actions.pickDirectory(videoPathPref::set)
fun pickAudioPath() = actions.pickDirectory(audioPathPref::set)
}
@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.viewmodel.settings.exoplayer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.russhwolf.settings.ObservableSettings
import kotlinx.coroutines.flow.StateFlow
import net.newpipe.app.viewmodel.settings.BooleanPreference
import net.newpipe.app.viewmodel.settings.StringPreference
import org.koin.core.annotation.KoinViewModel
// Keys mirror app/src/main/res/values/settings_keys.xml verbatim so the
// Compose screen and the legacy fragment share SharedPreferences.
private const val PROGRESSIVE_LOAD_INTERVAL_KEY = "progressive_load_interval"
private const val USE_EXOPLAYER_DECODER_FALLBACK_KEY = "use_exoplayer_decoder_fallback_key"
private const val DISABLE_MEDIA_TUNNELING_KEY = "disable_media_tunneling_key"
private const val ALWAYS_USE_SET_OUTPUT_SURFACE_WORKAROUND_KEY =
"always_use_exoplayer_set_output_surface_workaround_key"
internal const val PROGRESSIVE_LOAD_INTERVAL_1 = "1"
internal const val PROGRESSIVE_LOAD_INTERVAL_16 = "16"
internal const val PROGRESSIVE_LOAD_INTERVAL_64 = "64"
internal const val PROGRESSIVE_LOAD_INTERVAL_256 = "256"
internal const val PROGRESSIVE_LOAD_INTERVAL_EXOPLAYER_DEFAULT = "exoplayer_default"
@KoinViewModel
class ExoPlayerSettingsViewModel(
settings: ObservableSettings
) : ViewModel() {
private val progressiveLoadIntervalPref = StringPreference(
PROGRESSIVE_LOAD_INTERVAL_KEY, PROGRESSIVE_LOAD_INTERVAL_64, settings, viewModelScope
)
private val useDecoderFallbackPref = BooleanPreference(
USE_EXOPLAYER_DECODER_FALLBACK_KEY, false, settings, viewModelScope
)
private val disableMediaTunnelingPref = BooleanPreference(
DISABLE_MEDIA_TUNNELING_KEY, false, settings, viewModelScope
)
private val alwaysUseSetOutputSurfaceWorkaroundPref = BooleanPreference(
ALWAYS_USE_SET_OUTPUT_SURFACE_WORKAROUND_KEY, false, settings, viewModelScope
)
val progressiveLoadInterval: StateFlow<String> = progressiveLoadIntervalPref.state
val useDecoderFallback: StateFlow<Boolean> = useDecoderFallbackPref.state
val disableMediaTunneling: StateFlow<Boolean> = disableMediaTunnelingPref.state
val alwaysUseSetOutputSurfaceWorkaround: StateFlow<Boolean> =
alwaysUseSetOutputSurfaceWorkaroundPref.state
fun setProgressiveLoadInterval(value: String) = progressiveLoadIntervalPref.set(value)
fun toggleUseDecoderFallback(value: Boolean) = useDecoderFallbackPref.toggle(value)
fun toggleDisableMediaTunneling(value: Boolean) = disableMediaTunnelingPref.toggle(value)
fun toggleAlwaysUseSetOutputSurfaceWorkaround(value: Boolean) =
alwaysUseSetOutputSurfaceWorkaroundPref.toggle(value)
}
@@ -11,7 +11,7 @@ import com.russhwolf.settings.ObservableSettings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import net.newpipe.app.platform.HistoryActions
import net.newpipe.app.screen.settings.BooleanPreference
import net.newpipe.app.viewmodel.settings.BooleanPreference
import org.koin.core.annotation.KoinViewModel
// Mirrors app/src/main/res/values/settings_keys.xml — keep byte-identical so
@@ -0,0 +1,97 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.viewmodel.settings.lookfeel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.russhwolf.settings.ObservableSettings
import kotlinx.coroutines.flow.StateFlow
import net.newpipe.app.platform.LookFeelActions
import net.newpipe.app.viewmodel.settings.BooleanPreference
import net.newpipe.app.viewmodel.settings.StringPreference
import org.koin.core.annotation.KoinViewModel
// Keys mirror app/src/main/res/values/settings_keys.xml verbatim so the Compose
// screen and the legacy fragment share SharedPreferences.
private const val THEME_KEY = "theme"
private const val NIGHT_THEME_KEY = "night_theme"
private const val SHOW_HOLD_TO_APPEND_KEY = "show_hold_to_append"
private const val TABLET_MODE_KEY = "tablet_mode"
private const val LIST_VIEW_MODE_KEY = "list_view_mode"
private const val MAIN_TABS_POSITION_KEY = "main_tabs_position"
// Theme values
internal const val THEME_LIGHT = "light_theme"
internal const val THEME_DARK = "dark_theme"
internal const val THEME_BLACK = "black_theme"
internal const val THEME_AUTO = "auto_device_theme"
// Tablet mode values
internal const val TABLET_AUTO = "auto"
internal const val TABLET_ON = "on"
internal const val TABLET_OFF = "off"
// List view mode values
internal const val LIST_VIEW_AUTO = "auto"
internal const val LIST_VIEW_LIST = "list"
internal const val LIST_VIEW_GRID = "grid"
internal const val LIST_VIEW_CARD = "card"
@KoinViewModel
class LookFeelSettingsViewModel(
settings: ObservableSettings,
private val lookFeelActions: LookFeelActions
) : ViewModel() {
private val themePref = StringPreference(THEME_KEY, THEME_AUTO,
settings, viewModelScope)
private val nightThemePref = StringPreference(
NIGHT_THEME_KEY, THEME_DARK, settings, viewModelScope
)
private val showHoldToAppendPref = BooleanPreference(
SHOW_HOLD_TO_APPEND_KEY, true, settings, viewModelScope
)
private val tabletModePref = StringPreference(
TABLET_MODE_KEY, TABLET_AUTO, settings, viewModelScope
)
private val listViewModePref = StringPreference(
LIST_VIEW_MODE_KEY, LIST_VIEW_AUTO, settings, viewModelScope
)
private val mainTabsPositionPref = BooleanPreference(
MAIN_TABS_POSITION_KEY, false, settings, viewModelScope
)
val theme: StateFlow<String> = themePref.state
val nightTheme: StateFlow<String> = nightThemePref.state
val showHoldToAppend: StateFlow<Boolean> =
showHoldToAppendPref.state
val tabletMode: StateFlow<String> = tabletModePref.state
val listViewMode: StateFlow<String> = listViewModePref.state
val mainTabsPosition: StateFlow<Boolean> =
mainTabsPositionPref.state
fun setTheme(newValue: String) {
themePref.set(newValue)
if (newValue == THEME_AUTO) {
lookFeelActions.showSelectNightThemeToast()
}
lookFeelActions.applyTheme(newValue)
}
fun setNightTheme(newValue: String) {
nightThemePref.set(newValue)
lookFeelActions.applyNightTheme(newValue)
}
fun toggleShowHoldToAppend(value: Boolean) =
showHoldToAppendPref.toggle(value)
fun setTabletMode(value: String) = tabletModePref.set(value)
fun setListViewMode(value: String) = listViewModePref.set(value)
fun toggleMainTabsPosition(value: Boolean) =
mainTabsPositionPref.toggle(value)
fun openCaptionSettings() = lookFeelActions.openCaptionSettings()
}
@@ -9,8 +9,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.russhwolf.settings.ObservableSettings
import kotlinx.coroutines.flow.StateFlow
import net.newpipe.app.screen.settings.BooleanPreference
import net.newpipe.app.screen.settings.StringPreference
import net.newpipe.app.viewmodel.settings.BooleanPreference
import net.newpipe.app.viewmodel.settings.StringPreference
import org.koin.core.annotation.KoinViewModel
// Keys mirror app/src/main/res/values/settings_keys.xml verbatim so the
@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.viewmodel.settings.updates
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.russhwolf.settings.ObservableSettings
import net.newpipe.app.platform.UpdateActions
import net.newpipe.app.viewmodel.settings.BooleanPreference
import org.koin.core.annotation.KoinViewModel
private const val UPDATE_APP_KEY = "update_app_key"
@KoinViewModel
class UpdatesSettingsViewModel(
settings: ObservableSettings,
private val updateActions: UpdateActions
) : ViewModel() {
private val updateAppPref = BooleanPreference(
UPDATE_APP_KEY, false, settings, viewModelScope
)
val updateApp = updateAppPref.state
fun toggleUpdateApp(newValue: Boolean) {
updateAppPref.toggle(newValue)
if (newValue) {
updateActions.runManualCheck()
}
}
fun runManualCheck() = updateActions.runManualCheck()
}
@@ -1,17 +1,16 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.di.settings
import com.russhwolf.settings.NSUserDefaultsSettings
import com.russhwolf.settings.ObservableSettings
import com.russhwolf.settings.Settings
import org.koin.core.annotation.Singleton
import platform.Foundation.NSUserDefaults
/**
* Settings for iOS based on UserDefaultsSettings
*/
@Singleton
fun provideSettings(): Settings = NSUserDefaultsSettings(NSUserDefaults())
@Singleton(binds = [ObservableSettings::class, Settings::class])
fun provideSettings(): ObservableSettings =
NSUserDefaultsSettings(NSUserDefaults())
@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Singleton
@Singleton(binds = [BackupRestoreActions::class])
class IOSBackupRestoreActions : BackupRestoreActions {
override fun importDatabase() = log("importDatabase")
override fun exportDatabase() = log("exportDatabase")
override fun resetAllSettings() = log("resetAllSettings")
override fun importSubscriptions() = log("importSubscriptions")
override fun exportSubscriptions() = log("exportSubscriptions")
private fun log(name: String) =
Logger.i(messageString = "$name not implemented on iOS")
}
@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Singleton
@Singleton(binds = [ContentActions::class])
class IOSContentActions : ContentActions {
override fun openMainPageTabsChooser() =
log("openMainPageTabsChooser")
override fun openPeertubeInstanceList() =
log("openPeertubeInstanceList")
override fun onAppLanguageChanged() = log("onAppLanguageChanged")
private fun log(name: String) =
Logger.i(messageString = "$name not implemented on iOS")
}
@@ -1,2 +1,18 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import org.koin.core.annotation.Singleton
@Singleton(binds = [DebugActions::class])
class IOSDebugActions : DebugActions {
override fun crashTheApp() { throw RuntimeException("Dummy") }
override fun showErrorSnackbar() { /* no-op */ }
override fun createErrorNotification() { /* no-op */ }
override fun checkNewStreams() { /* no-op */ }
override fun showMemoryLeaks() { /* no-op */ }
override fun isLeakCanaryAvailable(): Boolean = false
}
@@ -1,2 +1,20 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Singleton
/**
* iOS implementation of [DownloadActions].
*
*/
@Singleton(binds = [DownloadActions::class])
class IOSDownloadActions : DownloadActions {
override fun pickDirectory(onPicked: (String) -> Unit) {
Logger.w(messageString = "pickDirectory not yet implemented on iOS")
}
}
@@ -1,2 +1,37 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Singleton
/**
* iOS implementation of [HistoryActions].
*
* Real impl will hook into the iOS-side history store once that lands; for
now
* every method is a logged no-op so the iOS target still compiles.
*/
@Singleton(binds = [HistoryActions::class])
class IOSHistoryActions : HistoryActions {
override fun wipeMetadataCache() {
Logger.i(messageString = "wipeMetadataCache not implemented on iOS")
}
override fun deleteWatchHistory() {
Logger.i(messageString = "deleteWatchHistory not implemented on iOS")
}
override fun deletePlaybackStates() {
Logger.i(messageString = "deletePlaybackStates not implemented on iOS")
}
override fun deleteSearchHistory() {
Logger.i(messageString = "deleteSearchHistory not implemented on iOS")
}
override fun clearRecaptchaCookies() {
Logger.i(messageString = "clearRecaptchaCookies not implemented on iOS")
}
override fun hasRecaptchaCookies(): Boolean = false
}
@@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Singleton
@Singleton(binds = [LookFeelActions::class])
class IOSLookFeelActions : LookFeelActions {
override fun applyTheme(newThemeKey: String) {
Logger.i(messageString = "applyTheme not implemented on iOS")
}
override fun applyNightTheme(newNightThemeKey: String) {
Logger.i(messageString = "applyNightTheme not implemented on iOS")
}
override fun showSelectNightThemeToast() {
Logger.i(messageString = "showSelectNightThemeToast not implemented on iOS")
}
override fun openCaptionSettings() {
Logger.i(messageString = "openCaptionSettings not implemented on iOS")
}
}
@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Singleton
@Singleton(binds = [UpdateActions::class])
class IOSUpdateActions : UpdateActions {
override fun runManualCheck() {
Logger.i(messageString = "runManualCheck not implemented on iOS")
}
}
@@ -1,17 +1,16 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.di.settings
import com.russhwolf.settings.ObservableSettings
import com.russhwolf.settings.PreferencesSettings
import com.russhwolf.settings.Settings
import java.util.prefs.Preferences
import org.koin.core.annotation.Singleton
/**
* Settings for JVM devices based on Java Preferences
*/
@Singleton
fun provideSettings(): Settings = PreferencesSettings(Preferences.userRoot())
@Singleton(binds = [ObservableSettings::class, Settings::class])
fun provideSettings(): ObservableSettings =
PreferencesSettings(Preferences.userRoot())
@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Singleton
@Singleton(binds = [BackupRestoreActions::class])
class JVMBackupRestoreActions : BackupRestoreActions {
override fun importDatabase() = log("importDatabase")
override fun exportDatabase() = log("exportDatabase")
override fun resetAllSettings() = log("resetAllSettings")
override fun importSubscriptions() = log("importSubscriptions")
override fun exportSubscriptions() = log("exportSubscriptions")
private fun log(name: String) =
Logger.i(messageString = "$name not implemented on JVM")
}
@@ -0,0 +1,15 @@
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Singleton
@Singleton(binds = [ContentActions::class])
class JVMContentActions : ContentActions {
override fun openMainPageTabsChooser() =
log("openMainPageTabsChooser")
override fun openPeertubeInstanceList() =
log("openPeertubeInstanceList")
override fun onAppLanguageChanged() = log("onAppLanguageChanged")
private fun log(name: String) =
Logger.i(messageString = "$name not implemented on JVM")
}
@@ -1,2 +1,18 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import org.koin.core.annotation.Singleton
@Singleton(binds = [DebugActions::class])
class JVMDebugActions : DebugActions {
override fun crashTheApp() = throw RuntimeException("Dummy")
override fun showErrorSnackbar() { /* no-op */ }
override fun createErrorNotification() { /* no-op */ }
override fun checkNewStreams() { /* no-op */ }
override fun showMemoryLeaks() { /* no-op */ }
override fun isLeakCanaryAvailable(): Boolean = false
}
@@ -1,2 +1,33 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import javax.swing.JFileChooser
import org.koin.core.annotation.Singleton
/**
* JVM (desktop) implementation of [DownloadActions].
*
* Uses Swing's [JFileChooser] in directories-only mode.
* Returns the absolute file path as the URI string.
*/
@Singleton(binds = [DownloadActions::class])
class JVMDownloadActions : DownloadActions {
override fun pickDirectory(onPicked: (String) -> Unit) {
val chooser = JFileChooser().apply {
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
dialogTitle = "Choose download folder"
}
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
val file = chooser.selectedFile ?: run {
Logger.w(messageString = "JFileChooser approved but returned null file")
return
}
onPicked(file.absolutePath)
}
}
}
@@ -1,2 +1,29 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Singleton
@Singleton(binds = [HistoryActions::class])
class JVMHistoryActions : HistoryActions {
override fun wipeMetadataCache() {
Logger.i(messageString = "wipeMetadataCache not implemented on JVM")
}
override fun deleteWatchHistory() {
Logger.i(messageString = "deleteWatchHistory not implemented on JVM")
}
override fun deletePlaybackStates() {
Logger.i(messageString = "deletePlaybackStates not implemented on JVM")
}
override fun deleteSearchHistory() {
Logger.i(messageString = "deleteSearchHistory not implemented on JVM")
}
override fun clearRecaptchaCookies() {
Logger.i(messageString = "clearRecaptchaCookies not implemented on JVM")
}
override fun hasRecaptchaCookies(): Boolean = false
}
@@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Singleton
@Singleton(binds = [LookFeelActions::class])
class JVMLookFeelActions : LookFeelActions {
override fun applyTheme(newThemeKey: String) {
Logger.i(messageString = "applyTheme not implemented on JVM")
}
override fun applyNightTheme(newNightThemeKey: String) {
Logger.i(messageString = "applyNightTheme not implemented on JVM")
}
override fun showSelectNightThemeToast() {
Logger.i(messageString = "showSelectNightThemeToast not implemented on JVM")
}
override fun openCaptionSettings() {
Logger.i(messageString = "openCaptionSettings not implemented on JVM")
}
}
@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.platform
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Singleton
@Singleton(binds = [UpdateActions::class])
class JVMUpdateActions : UpdateActions {
override fun runManualCheck() {
Logger.i(messageString = "runManualCheck not implemented on JVM")
}
}