Complete the debug screen migration to Compose with Nav3

- Completed the UI and logic for pending items of Debug screen using Jetpack Compose.
- Implemented a new navigation system for settings using the navigation3
  lib as it is now stable.
- Reuses the `ScaffoldWithToolbar` composable and removed the previous
  `Toolbar` composable to avoid redundancy in code.
- Refactored the `SettingsViewModel` to use a `BooleanPreference` helper
  class to reuse and reducing boilerplate for state management.
- Created a shared `TextBase` composable to remove duplicated UI logic
  between `SwitchPreference` and `TextPreference`.
- Move the build-variant-dependent logic for LeakCanary and reused it in
  Compose and Fragment, this logic is used for ensuring the leak canary
  fields are only enabled in debug variants.
- Fixed a layout bug in `SwitchPreference` where long summary text could
  misalign the switch component and also adjusted the paddings for consistency.
This commit is contained in:
Hatake Kakashri 2025-11-15 11:23:08 +05:30
parent 61c25d4589
commit 017f991fe2
20 changed files with 783 additions and 365 deletions

View File

@ -262,9 +262,14 @@ dependencies {
implementation(libs.androidx.compose.ui.text) // Needed for parsing HTML to AnnotatedString
implementation(libs.androidx.compose.material.icons.extended)
// Jetpack navigatio3
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.viewmodel)
// Jetpack Compose related dependencies
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
// Coroutines interop
implementation(libs.kotlinx.coroutines.rx3)

View File

@ -1,20 +0,0 @@
package org.schabi.newpipe.settings;
import android.content.Intent;
import leakcanary.LeakCanary;
/**
* Build variant dependent (BVD) leak canary API implementation for the debug settings fragment.
* This class is loaded via reflection by
* {@link DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI}.
*/
@SuppressWarnings("unused") // Class is used but loaded via reflection
public class DebugSettingsBVDLeakCanary
implements DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI {
@Override
public Intent getNewLeakDisplayActivityIntent() {
return LeakCanary.INSTANCE.newLeakDisplayActivityIntent();
}
}

View File

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings
import android.content.Intent
import leakcanary.LeakCanary.newLeakDisplayActivityIntent
/**
* Build variant dependent (BVD) leak canary API implementation for the debug settings fragment.
* This class is loaded via reflection by
* [DebugSettingsBVDLeakCanaryAPI].
*/
@Suppress("unused") // Class is used but loaded via reflection
class DebugSettingsBVDLeakCanary :
DebugSettingsBVDLeakCanaryAPI {
override fun getNewLeakDisplayActivityIntent(): Intent {
return newLeakDisplayActivityIntent()
}
}

View File

@ -1,27 +0,0 @@
package org.schabi.newpipe.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import org.schabi.newpipe.R
import org.schabi.newpipe.settings.viewmodel.SettingsViewModel
import org.schabi.newpipe.ui.SwitchPreference
import org.schabi.newpipe.ui.theme.SizeTokens
@Composable
fun DebugScreen(viewModel: SettingsViewModel, modifier: Modifier = Modifier) {
val settingsLayoutRedesign by viewModel.settingsLayoutRedesign.collectAsState()
Column(modifier = modifier) {
SwitchPreference(
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
R.string.settings_layout_redesign,
settingsLayoutRedesign,
viewModel::toggleSettingsLayoutRedesign
)
}
}

View File

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings
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
companion object {
const val IMPL_CLASS = "org.schabi.newpipe.settings.DebugSettingsBVDLeakCanary"
}
}

View File

@ -1,6 +1,11 @@
/*
* 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 org.schabi.newpipe.settings;
import android.content.Intent;
import android.os.Bundle;
import androidx.preference.Preference;
@ -88,15 +93,4 @@ public class DebugSettingsFragment extends BasePreferenceFragment {
return Optional.empty();
}
}
/**
* Build variant dependent (BVD) leak canary API for this fragment.
* Why is LeakCanary not used directly? Because it can't be assured
*/
public interface DebugSettingsBVDLeakCanaryAPI {
String IMPL_CLASS =
"org.schabi.newpipe.settings.DebugSettingsBVDLeakCanary";
Intent getNewLeakDisplayActivityIntent();
}
}

View File

@ -1,23 +0,0 @@
package org.schabi.newpipe.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.TextPreference
@Composable
fun SettingsScreen(
onSelectSettingOption: (SettingsScreenKey) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
TextPreference(
title = R.string.settings_category_debug_title,
onClick = { onSelectSettingOption(SettingsScreenKey.DEBUG) }
)
HorizontalDivider(color = Color.Black)
}
}

View File

@ -1,85 +1,30 @@
/*
* 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 org.schabi.newpipe.settings
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import dagger.hilt.android.AndroidEntryPoint
import org.schabi.newpipe.R
import org.schabi.newpipe.settings.viewmodel.SettingsViewModel
import org.schabi.newpipe.ui.Toolbar
import org.schabi.newpipe.settings.navigation.SettingsNavigation
import org.schabi.newpipe.ui.theme.AppTheme
const val SCREEN_TITLE_KEY = "SCREEN_TITLE_KEY"
@AndroidEntryPoint
class SettingsV2Activity : ComponentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
var screenTitle by remember { mutableIntStateOf(SettingsScreenKey.ROOT.screenTitle) }
navController.addOnDestinationChangedListener { _, _, arguments ->
screenTitle =
arguments?.getInt(SCREEN_TITLE_KEY) ?: SettingsScreenKey.ROOT.screenTitle
}
AppTheme {
Scaffold(topBar = {
Toolbar(
title = stringResource(id = screenTitle),
hasSearch = true,
onSearchQueryChange = null // TODO: Add suggestions logic
)
}) { padding ->
NavHost(
navController = navController,
startDestination = SettingsScreenKey.ROOT.name,
modifier = Modifier.padding(padding)
) {
composable(
SettingsScreenKey.ROOT.name,
listOf(createScreenTitleArg(SettingsScreenKey.ROOT.screenTitle))
) {
SettingsScreen(onSelectSettingOption = { screen ->
navController.navigate(screen.name)
})
}
composable(
SettingsScreenKey.DEBUG.name,
listOf(createScreenTitleArg(SettingsScreenKey.DEBUG.screenTitle))
) {
DebugScreen(settingsViewModel)
}
}
}
SettingsNavigation(
onExitSettings = { finish() },
)
}
}
}
}
fun createScreenTitleArg(@StringRes screenTitle: Int) = navArgument(SCREEN_TITLE_KEY) {
defaultValue = screenTitle
}
enum class SettingsScreenKey(@StringRes val screenTitle: Int) {
ROOT(R.string.settings),
DEBUG(R.string.settings_category_debug_title)
}

View File

@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings.navigation
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import org.schabi.newpipe.R
import org.schabi.newpipe.settings.screens.DebugScreen
import org.schabi.newpipe.settings.screens.SettingsHomeScreen
import org.schabi.newpipe.ui.screens.Screens
@Composable
fun SettingsNavigation(onExitSettings: () -> Unit) {
val backStack = rememberNavBackStack(Screens.Settings.Home)
val handleBack: () -> Unit = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
onExitSettings()
}
}
NavDisplay(
backStack = backStack,
onBack = handleBack,
entryProvider = entryProvider {
entry<Screens.Settings.Home> { SettingsHomeScreen(backStack, handleBack) }
entry<Screens.Settings.Player> { Text(stringResource(id = R.string.settings_category_player_title)) }
entry<Screens.Settings.Behaviour> { Text(stringResource(id = R.string.settings_category_player_behavior_title)) }
entry<Screens.Settings.Download> { Text(stringResource(id = R.string.settings_category_downloads_title)) }
entry<Screens.Settings.LookFeel> { Text(stringResource(id = R.string.settings_category_look_and_feel_title)) }
entry<Screens.Settings.HistoryCache> { Text(stringResource(id = R.string.settings_category_history_title)) }
entry<Screens.Settings.Content> { Text(stringResource(id = R.string.settings_category_content_title)) }
entry<Screens.Settings.Feed> { Text(stringResource(id = R.string.settings_category_feed_title)) }
entry<Screens.Settings.Services> { Text(stringResource(id = R.string.settings_category_services_title)) }
entry<Screens.Settings.Language> { Text(stringResource(id = R.string.settings_category_language_title)) }
entry<Screens.Settings.BackupRestore> { Text(stringResource(id = R.string.settings_category_backup_restore_title)) }
entry<Screens.Settings.Updates> { Text(stringResource(id = R.string.settings_category_updates_title)) }
entry<Screens.Settings.Debug> { DebugScreen(backStack) }
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
)
)
}

View File

@ -0,0 +1,125 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil
import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.settings.viewmodel.SettingsViewModel
import org.schabi.newpipe.ui.SwitchPreference
import org.schabi.newpipe.ui.TextPreference
import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar
private const val DUMMY = "Dummy"
@Composable
fun DebugScreen(
backStack: NavBackStack<NavKey>,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val settingsLayoutRedesign by viewModel.settingsLayoutRedesign.collectAsState()
val isLeakCanaryAvailable by viewModel.isLeakCanaryAvailable.collectAsState()
val allowHeapDumping by viewModel.allowHeapDumping.collectAsState()
val allowDisposedExceptions by viewModel.allowDisposedExceptions.collectAsState()
val showOriginalTimeAgo by viewModel.showOriginalTimeAgo.collectAsState()
val showCrashThePlayer by viewModel.showCrashThePlayer.collectAsState()
ScaffoldWithToolbar(
title = stringResource(id = R.string.settings_category_debug_title),
onBackClick = { backStack.removeLastOrNull() }
) { paddingValues ->
Column(modifier = modifier.padding(paddingValues)) {
SwitchPreference(
title = R.string.leakcanary,
summary = if (isLeakCanaryAvailable) R.string.enable_leak_canary_summary else R.string.leak_canary_not_available,
isChecked = allowHeapDumping,
onCheckedChange = viewModel::toggleAllowHeapDumping,
enabled = isLeakCanaryAvailable
)
TextPreference(
title = R.string.show_memory_leaks,
summary = if (isLeakCanaryAvailable) null else R.string.leak_canary_not_available,
onClick = {
viewModel.getLeakDisplayActivityIntent()?.let {
context.startActivity(it)
}
},
enabled = isLeakCanaryAvailable
)
SwitchPreference(
title = R.string.enable_disposed_exceptions_title,
summary = R.string.enable_disposed_exceptions_summary,
isChecked = allowDisposedExceptions,
onCheckedChange = viewModel::toggleAllowDisposedExceptions
)
SwitchPreference(
title = R.string.show_original_time_ago_title,
summary = R.string.show_original_time_ago_summary,
isChecked = showOriginalTimeAgo,
onCheckedChange = viewModel::toggleShowOriginalTimeAgo
)
SwitchPreference(
title = R.string.show_crash_the_player_title,
summary = R.string.show_crash_the_player_summary,
isChecked = showCrashThePlayer,
onCheckedChange = viewModel::toggleShowCrashThePlayer
)
TextPreference(
title = R.string.check_new_streams,
onClick = viewModel::checkNewStreams
)
TextPreference(
title = R.string.crash_the_app,
onClick = {
throw RuntimeException(DUMMY)
}
)
TextPreference(
title = R.string.show_error_snackbar,
onClick = {
ErrorUtil.showUiErrorSnackbar(
context,
DUMMY, RuntimeException(DUMMY)
)
}
)
TextPreference(
title = R.string.create_error_notification,
onClick = {
createNotification(
context,
ErrorInfo(
RuntimeException(DUMMY),
UserAction.UI_ERROR,
DUMMY
)
)
}
)
SwitchPreference(
title = R.string.settings_layout_redesign,
isChecked = settingsLayoutRedesign,
onCheckedChange = viewModel::toggleSettingsLayoutRedesign
)
}
}
}

View File

@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings.screens
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.TextPreference
import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar
import org.schabi.newpipe.ui.screens.Screens
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsHomeScreen(backStack: NavBackStack<NavKey>, handleBack: () -> Unit) {
ScaffoldWithToolbar(
title = stringResource(id = R.string.settings),
onBackClick = {
handleBack()
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
item {
TextPreference(
title = R.string.settings_category_player_title,
icon = R.drawable.ic_play_arrow,
onClick = { backStack.add(Screens.Settings.Player) }
)
}
item {
TextPreference(
title = R.string.settings_category_player_behavior_title,
icon = R.drawable.ic_settings,
onClick = { backStack.add(Screens.Settings.Behaviour) }
)
}
item {
TextPreference(
title = R.string.settings_category_downloads_title,
icon = R.drawable.ic_file_download,
onClick = { backStack.add(Screens.Settings.Download) }
)
}
item {
TextPreference(
title = R.string.settings_category_look_and_feel_title,
icon = R.drawable.ic_palette,
onClick = { backStack.add(Screens.Settings.LookFeel) }
)
}
item {
TextPreference(
title = R.string.settings_category_history_title,
icon = R.drawable.ic_history,
onClick = { backStack.add(Screens.Settings.HistoryCache) }
)
}
item {
TextPreference(
title = R.string.settings_category_content_title,
icon = R.drawable.ic_tv,
onClick = { backStack.add(Screens.Settings.Content) }
)
}
item {
TextPreference(
title = R.string.settings_category_feed_title,
icon = R.drawable.ic_rss_feed,
onClick = { backStack.add(Screens.Settings.Feed) }
)
}
item {
TextPreference(
title = R.string.settings_category_services_title,
icon = R.drawable.ic_subscriptions,
onClick = { backStack.add(Screens.Settings.Services) }
)
}
item {
TextPreference(
title = R.string.settings_category_language_title,
icon = R.drawable.ic_language,
onClick = { backStack.add(Screens.Settings.Language) }
)
}
item {
TextPreference(
title = R.string.settings_category_backup_restore_title,
icon = R.drawable.ic_backup,
onClick = { backStack.add(Screens.Settings.BackupRestore) }
)
}
// Show Updates only on release builds
if (!BuildConfig.DEBUG) {
item {
TextPreference(
title = R.string.settings_category_updates_title,
icon = R.drawable.ic_newpipe_update,
onClick = { backStack.add(Screens.Settings.Updates) }
)
}
}
// Show Debug only on debug builds
if (BuildConfig.DEBUG) {
item {
TextPreference(
title = R.string.settings_category_debug_title,
icon = R.drawable.ic_bug_report,
onClick = { backStack.add(Screens.Settings.Debug) }
)
}
}
}
}
}

View File

@ -1,40 +1,122 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings.viewmodel
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import androidx.annotation.StringRes
import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.schabi.newpipe.R
import org.schabi.newpipe.local.feed.notifications.NotificationWorker
import org.schabi.newpipe.settings.DebugSettingsBVDLeakCanaryAPI
import org.schabi.newpipe.util.Localization
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ApplicationContext context: Context,
private val preferenceManager: SharedPreferences
preferenceManager: SharedPreferences
) : AndroidViewModel(context.applicationContext as Application) {
private var _settingsLayoutRedesignPref: Boolean
get() = preferenceManager.getBoolean(
Localization.compatGetString(getApplication(), R.string.settings_layout_redesign_key),
false
)
set(value) {
preferenceManager.edit().putBoolean(
Localization.compatGetString(getApplication(), R.string.settings_layout_redesign_key),
value
).apply()
}
private val _settingsLayoutRedesign: MutableStateFlow<Boolean> =
MutableStateFlow(_settingsLayoutRedesignPref)
val settingsLayoutRedesign = _settingsLayoutRedesign.asStateFlow()
private val bvdLeakCanaryApi: DebugSettingsBVDLeakCanaryAPI? = runCatching {
// Try to find the implementation of the LeakCanary API
Class.forName(DebugSettingsBVDLeakCanaryAPI.IMPL_CLASS)
.getDeclaredConstructor()
.newInstance() as DebugSettingsBVDLeakCanaryAPI
}.getOrNull()
private val _isLeakCanaryAvailable = MutableStateFlow(bvdLeakCanaryApi != null)
fun toggleSettingsLayoutRedesign(newState: Boolean) {
_settingsLayoutRedesign.value = newState
_settingsLayoutRedesignPref = newState
private val allowHeapDumpingPref = BooleanPreference(
R.string.allow_heap_dumping_key,
false,
context.applicationContext,
preferenceManager
)
private val allowDisposedExceptionsPref = BooleanPreference(
R.string.allow_disposed_exceptions_key,
false,
context.applicationContext,
preferenceManager
)
private val showOriginalTimeAgoPref =
BooleanPreference(
R.string.show_original_time_ago_key,
false,
context.applicationContext,
preferenceManager
)
private val showCrashThePlayerPref = BooleanPreference(
R.string.show_crash_the_player_key,
false,
context.applicationContext,
preferenceManager
)
private val settingsLayoutRedesignPref =
BooleanPreference(
R.string.settings_layout_redesign_key,
false,
context.applicationContext,
preferenceManager
)
val isLeakCanaryAvailable = _isLeakCanaryAvailable.asStateFlow()
val allowHeapDumping = allowHeapDumpingPref.state
val allowDisposedExceptions = allowDisposedExceptionsPref.state
val showOriginalTimeAgo = showOriginalTimeAgoPref.state
val showCrashThePlayer = showCrashThePlayerPref.state
val settingsLayoutRedesign = settingsLayoutRedesignPref.state
fun getLeakDisplayActivityIntent(): Intent? {
return bvdLeakCanaryApi?.getNewLeakDisplayActivityIntent()
}
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 checkNewStreams() {
NotificationWorker.runNow(getApplication())
}
fun toggleSettingsLayoutRedesign(newValue: Boolean) =
settingsLayoutRedesignPref.toggle(newValue)
}
/**
* Encapsulates the state and update logic for a boolean preference.
*
* @param keyResId The string resource ID for the preference key.
* @param defaultValue The default value of the preference.
* @param context The application context.
* @param preferenceManager The [SharedPreferences] manager.
*/
private class BooleanPreference(
@StringRes keyResId: Int,
defaultValue: Boolean,
context: Context,
private val preferenceManager: SharedPreferences
) {
private val key = Localization.compatGetString(context, keyResId)
private val _state = MutableStateFlow(preferenceManager.getBoolean(key, defaultValue))
val state: StateFlow<Boolean> = _state.asStateFlow()
fun toggle(newValue: Boolean) {
preferenceManager.edit { putBoolean(key, newValue) }
_state.value = newValue
}
}

View File

@ -1,21 +1,22 @@
/*
* 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 org.schabi.newpipe.ui
import androidx.annotation.StringRes
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import org.schabi.newpipe.ui.theme.SizeTokens
@Composable
@ -24,30 +25,25 @@ fun SwitchPreference(
@StringRes title: Int,
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
@StringRes summary: Int? = null
@StringRes summary: Int? = null,
enabled: Boolean = true
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier.fillMaxWidth()
modifier = modifier
.fillMaxWidth()
.padding(SizeTokens.SpacingSmall)
) {
Column {
Text(
text = stringResource(id = title),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Start,
)
summary?.let {
Text(
text = stringResource(id = summary),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
)
}
Column(
modifier = Modifier.weight(1f)
) {
TextBase(title = title, summary = summary, enabled = enabled)
}
Spacer(modifier = Modifier.width(SizeTokens.SpacingSmall))
Switch(checked = isChecked, onCheckedChange = onCheckedChange)
Spacer(modifier = Modifier.width(SizeTokens.SpacingExtraSmall))
Switch(
checked = isChecked,
onCheckedChange = onCheckedChange,
enabled = enabled
)
}
}

View File

@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui
import androidx.annotation.StringRes
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import org.schabi.newpipe.R
/**
* A base composable that displays a title and an optional summary text. Used in settings preference
* items such as TextPreference and SwitchPreference
*
* @param title the resource ID of the string to be used as the title
* @param summary the optional resource ID of the string to be used as the summary
* @param enabled whether the text should be displayed in an enabled or disabled state
*/
@Composable
internal fun TextBase(
@StringRes title: Int,
@StringRes summary: Int?,
enabled: Boolean = true
) {
Column {
Text(
text = stringResource(id = title),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Start,
color = if (enabled) Color.Unspecified else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
summary?.let {
Text(
text = stringResource(id = summary),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
color = if (enabled) Color.Unspecified else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
)
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
@Composable
fun TextBasePreview() {
TextBase(R.string.settings_category_debug_title, R.string.settings_category_debug_title)
}

View File

@ -1,3 +1,9 @@
/*
* 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 org.schabi.newpipe.ui
import androidx.annotation.DrawableRes
@ -13,13 +19,11 @@ 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.material3.Text
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import org.schabi.newpipe.ui.theme.SizeTokens
@Composable
@ -29,38 +33,27 @@ fun TextPreference(
@DrawableRes icon: Int? = null,
@StringRes summary: Int? = null,
onClick: () -> Unit,
enabled: Boolean = true
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.Start,
modifier = modifier
.fillMaxWidth()
.padding(SizeTokens.SpacingSmall)
.defaultMinSize(minHeight = SizeTokens.SpaceMinSize)
.clickable { onClick() }
.clickable(enabled = enabled) { onClick() }
) {
icon?.let {
Icon(
painter = painterResource(id = icon),
contentDescription = "icon for $title preference"
contentDescription = "icon for $title preference",
tint = if (enabled) Color.Unspecified else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
Spacer(modifier = Modifier.width(SizeTokens.SpacingSmall))
}
Column {
Text(
text = stringResource(id = title),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Start,
)
summary?.let {
Text(
text = stringResource(id = summary),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
)
}
TextBase(title = title, summary = summary, enabled = enabled)
}
}
}

View File

@ -1,129 +0,0 @@
package org.schabi.newpipe.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.ui.theme.SizeTokens
@Composable
fun TextAction(text: String, modifier: Modifier = Modifier) {
Text(text = text, color = MaterialTheme.colorScheme.onSurface, modifier = modifier)
}
@Composable
fun NavigationIcon() {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back",
modifier = Modifier.padding(horizontal = SizeTokens.SpacingExtraSmall)
)
}
@Composable
fun SearchSuggestionItem(text: String) {
// TODO: Add more components here to display all the required details of a search suggestion item.
Text(text = text)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Toolbar(
title: String,
modifier: Modifier = Modifier,
hasNavigationIcon: Boolean = true,
hasSearch: Boolean = false,
onSearchQueryChange: ((String) -> List<String>)? = null,
actions: @Composable RowScope.() -> Unit = {}
) {
var isSearchActive by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
Column {
TopAppBar(
title = { Text(text = title) },
modifier = modifier,
navigationIcon = { if (hasNavigationIcon) NavigationIcon() },
actions = {
actions()
if (hasSearch) {
IconButton(onClick = { isSearchActive = true }) {
Icon(
painterResource(id = R.drawable.ic_search),
contentDescription = stringResource(id = R.string.search),
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
)
if (isSearchActive) {
SearchBar(
query = query,
onQueryChange = { query = it },
onSearch = {},
placeholder = {
Text(text = stringResource(id = R.string.search))
},
active = true,
onActiveChange = {
isSearchActive = it
}
) {
onSearchQueryChange?.invoke(query)?.takeIf { it.isNotEmpty() }
?.map { suggestionText -> SearchSuggestionItem(text = suggestionText) }
?: run {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column {
Text(text = "╰(°●°╰)")
Text(text = stringResource(id = R.string.search_no_results))
}
}
}
}
}
}
}
@Preview
@Composable
fun ToolbarPreview() {
AppTheme {
Toolbar(
title = "Title",
hasSearch = true,
onSearchQueryChange = { emptyList() },
actions = {
TextAction(text = "Action1")
TextAction(text = "Action2")
}
)
}
}

View File

@ -1,8 +1,20 @@
/*
* 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 org.schabi.newpipe.ui.components.common
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
@ -10,11 +22,23 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -22,42 +46,148 @@ fun ScaffoldWithToolbar(
title: String,
onBackClick: () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
hasSearch: Boolean = false,
onSearchQueryChange: ((String) -> List<String>)? = null,
onSearchAction: ((String) -> Unit)? = null,
searchPlaceholder: @Composable (() -> Unit)? = null,
content: @Composable (PaddingValues) -> Unit
) {
var isSearchActive by rememberSaveable { mutableStateOf(false) }
var query by rememberSaveable { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = title) },
// TODO decide whether to use default colors instead
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null
if (isSearchActive) {
SearchBar(
inputField = {
SearchBarDefaults.InputField(
query = query,
onQueryChange = { query = it },
onSearch = {
onSearchAction?.invoke(it)
isSearchActive = false
},
expanded = true,
onExpandedChange = { isSearchActive = it },
placeholder = searchPlaceholder ?: {
Text(stringResource(id = R.string.search))
},
leadingIcon = {
IconButton(onClick = {
isSearchActive = false
query = ""
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.navigate_back)
)
}
}
)
},
expanded = true,
onExpandedChange = { isSearchActive = it },
) {
val suggestions = onSearchQueryChange?.invoke(query) ?: emptyList()
if (suggestions.isNotEmpty()) {
Column(Modifier.fillMaxWidth()) {
suggestions.forEach { suggestionText ->
SearchSuggestionItem(text = suggestionText)
}
}
} else {
DefaultSearchNoResults()
}
},
actions = actions
)
}
} else {
TopAppBar(
title = { Text(text = title) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.navigate_back)
)
}
},
actions = {
actions()
// existing actions
if (hasSearch) {
// Show search icon
IconButton(onClick = { isSearchActive = true }) {
Icon(
painter = painterResource(id = R.drawable.ic_search),
contentDescription = stringResource(id = R.string.search),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
)
}
},
content = content
)
}
@Composable
fun SearchSuggestionItem(text: String) {
// TODO: Add more components here to display all the required details of a search suggestion item.
Text(text = text)
}
@Composable
private fun DefaultSearchNoResults() {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column {
Text(text = "╰(°●°╰)")
Text(text = stringResource(id = R.string.search_no_results))
}
}
}
@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ScaffoldWithToolbarPreview() {
ScaffoldWithToolbar(
title = "Example",
onBackClick = {},
content = {}
)
AppTheme {
ScaffoldWithToolbar(
title = "Example",
onBackClick = {},
hasSearch = true,
onSearchQueryChange = { query ->
if (query.isNotBlank()) {
listOf("Suggestion 1 for $query", "Suggestion 2 for $query")
} else {
emptyList()
}
},
onSearchAction = { query ->
println("Searching for: $query")
},
content = { paddingValues ->
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Screen Content")
}
}
)
}
}

View File

@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui.screens
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
/**
* Represents the screen keys for the app.
*/
sealed interface Screens : NavKey {
sealed interface Settings : Screens {
@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
}
}

View File

@ -154,11 +154,15 @@
<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>
<string name="popup_playing_toast">Playing in popup mode</string>
<string name="content">Content</string>
@ -899,4 +903,5 @@
<string name="youtube_player_http_403">HTTP error 403 received from server while playing, likely caused by an IP ban or streaming URL deobfuscation issues</string>
<string name="sign_in_confirm_not_bot_error">%1$s refused to provide data, asking for a login to confirm the requester is not a bot.\n\nYour IP might have been temporarily banned by %1$s, you can wait some time or switch to a different IP (for example by turning on/off a VPN, or by switching from WiFi to mobile data).</string>
<string name="unsupported_content_in_country">This content is not available for the currently selected content country.\n\nChange your selection from \"Settings > Content > Default content country\".</string>
<string name="navigate_back">Navigate back</string>
</resources>

View File

@ -24,6 +24,7 @@ exoplayer = "2.19.1"
fragment-compose = "1.8.9"
groupie = "2.10.1"
hilt = "2.57.2"
hilt-navigation-compose = "1.3.0"
jsoup = "1.21.2"
junit = "4.13.2"
junit-ext = "1.3.0"
@ -39,7 +40,7 @@ markwon = "4.6.2"
material = "1.11.0" # TODO: update to newer version after bug is fixed. See https://github.com/TeamNewPipe/NewPipe/pull/13018
media = "1.7.1"
mockitoCore = "5.21.0"
navigation-compose = "2.8.3"
nav3Core = "1.0.0"
okhttp = "5.3.2"
paging-compose = "3.3.2"
phoenix = "3.0.0"
@ -89,12 +90,15 @@ androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayo
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core" }
androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" }
androidx-fragment-compose = { module = "androidx.fragment:fragment-compose", version.ref = "fragment-compose" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junit-ext" }
androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-media = { module = "androidx.media:media", version.ref = "media" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-navigation3-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging-compose" }
androidx-preference = { module = "androidx.preference:preference", version.ref = "preference" }
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }