diff --git a/ b/ index a5f16add8..de46f2eb0 100644 --- a/ +++ b/ @@ -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 = listOf( + module { + single { applicationContext } + single { AppLegacyHooks(application) } + } + ) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67a33742b..ece74ecd2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,6 +51,10 @@ + + diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 6aa87b4bd..99852d78c 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -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(); diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeComposeActivity.kt b/app/src/main/java/org/schabi/newpipe/NewPipeComposeActivity.kt index a5f16add8..3832c7692 100644 --- a/app/src/main/java/org/schabi/newpipe/NewPipeComposeActivity.kt +++ b/app/src/main/java/org/schabi/newpipe/NewPipeComposeActivity.kt @@ -1,2 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 = listOf( + module { + single { applicationContext } + single { AppLegacyHooks(application) } + } + ) +} diff --git a/app/src/main/java/org/schabi/newpipe/platform/AppLegacyHooks.kt b/app/src/main/java/org/schabi/newpipe/platform/AppLegacyHooks.kt index bafdb193c..ac6d44ada 100644 --- a/app/src/main/java/org/schabi/newpipe/platform/AppLegacyHooks.kt +++ b/app/src/main/java/org/schabi/newpipe/platform/AppLegacyHooks.kt @@ -1,2 +1,462 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/platform/ComposeLauncher.kt b/app/src/main/java/org/schabi/newpipe/platform/ComposeLauncher.kt index 8958c9b43..f85c8e031 100644 --- a/app/src/main/java/org/schabi/newpipe/platform/ComposeLauncher.kt +++ b/app/src/main/java/org/schabi/newpipe/platform/ComposeLauncher.kt @@ -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) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/schabi/newpipe/platform/DirectoryPickerRegistry.kt b/app/src/main/java/org/schabi/newpipe/platform/DirectoryPickerRegistry.kt index bafdb193c..f9d5c3aec 100644 --- a/app/src/main/java/org/schabi/newpipe/platform/DirectoryPickerRegistry.kt +++ b/app/src/main/java/org/schabi/newpipe/platform/DirectoryPickerRegistry.kt @@ -1,2 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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? = 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 + } + } + }) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/platform/ImportExportLauncherRegistry.kt b/app/src/main/java/org/schabi/newpipe/platform/ImportExportLauncherRegistry.kt new file mode 100644 index 000000000..2d54141f9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/platform/ImportExportLauncherRegistry.kt @@ -0,0 +1,56 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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? = null + + @Volatile + internal var createLauncher: ActivityResultLauncher? = 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 + } + }) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsBVDLeakCanaryAPI.kt b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsBVDLeakCanaryAPI.kt index 777df5084..ed6444b4e 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsBVDLeakCanaryAPI.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsBVDLeakCanaryAPI.kt @@ -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 diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java index f822e46f2..0ceadd07e 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java @@ -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 getBVDLeakCanary() { try { // Try to find the implementation of the LeakCanary API diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 400cb3f3a..193dc3250 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -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) { diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 60ac20d0a..d37ae67a1 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -1513,4 +1513,7 @@ @string/image_quality_medium_key @string/image_quality_high_key + + + settings_layout_redesign_key diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e9b21b00..d558c3071 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -154,13 +154,11 @@ Video and audio History and cache Appearance - Look and feel Debug Updates Player notification Configure current playing stream notification Backup and restore - Content Services Language Playing in background @@ -909,9 +907,8 @@ Blogpost Look and feel Content - Services - Language Navigate back Enable the Redesigned Settings page + Restart NewPipe to apply the new theme diff --git a/gradle.properties b/gradle.properties index dd79a45b2..1d6a34e56 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db160ecc9..7f353bfad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index fe5524336..abfef4143 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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) } } diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/ComposeActivity.kt b/shared/src/androidMain/kotlin/net/newpipe/app/ComposeActivity.kt index b5b871a35..c7219064c 100644 --- a/shared/src/androidMain/kotlin/net/newpipe/app/ComposeActivity.kt +++ b/shared/src/androidMain/kotlin/net/newpipe/app/ComposeActivity.kt @@ -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 { + return listOf( + module { single { 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( + startDestination = Json.decodeFromString( 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 } diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt b/shared/src/androidMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt index 8c2f7b4ed..7c939b079 100644 --- a/shared/src/androidMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt +++ b/shared/src/androidMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt @@ -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) + ) \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidBackupRestoreActions.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidBackupRestoreActions.kt new file mode 100644 index 000000000..f6463197e --- /dev/null +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidBackupRestoreActions.kt @@ -0,0 +1,22 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* SPDX-License-Identifier: GPL-3.0-or-later +*/ + +package net.newpipe.app.platform + +import org.koin.core.annotation.Singleton + +@Singleton(binds = [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() +} + + + diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidContentActions.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidContentActions.kt new file mode 100644 index 000000000..893fe9d11 --- /dev/null +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidContentActions.kt @@ -0,0 +1,17 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* SPDX-License-Identifier: GPL-3.0-or-later +*/ + +package net.newpipe.app.platform + +import org.koin.core.annotation.Singleton + +@Singleton(binds = [ContentActions::class]) +class AndroidContentActions( + private val legacyHooks: AndroidLegacyHooks +) : ContentActions { + override fun openMainPageTabsChooser() = legacyHooks.openMainPageTabsChooser() + override fun openPeertubeInstanceList() = legacyHooks.openPeertubeInstanceList() + override fun onAppLanguageChanged() = legacyHooks.onAppLanguageChanged() +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidDebugActions.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidDebugActions.kt index 38452c7f0..86f9a1e52 100644 --- a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidDebugActions.kt +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidDebugActions.kt @@ -1,2 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidDownloadActions.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidDownloadActions.kt index 38452c7f0..90a21f184 100644 --- a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidDownloadActions.kt +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidDownloadActions.kt @@ -1,2 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package net.newpipe.app.platform +import org.koin.core.annotation.Singleton + +@Singleton(binds = [DownloadActions::class]) +class AndroidDownloadActions( + private val legacyHooks: AndroidLegacyHooks +) : DownloadActions { + + override fun pickDirectory(onPicked: (String) -> Unit) = + legacyHooks.requestDirectoryPicker(onPicked) +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidHistoryActions.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidHistoryActions.kt index 38452c7f0..1a40e0321 100644 --- a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidHistoryActions.kt +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidHistoryActions.kt @@ -1,2 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package net.newpipe.app.platform +import org.koin.core.annotation.Singleton + +@Singleton(binds = [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() +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidLegacyHooks.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidLegacyHooks.kt index 38452c7f0..92e7b199a 100644 --- a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidLegacyHooks.kt +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidLegacyHooks.kt @@ -1,2 +1,55 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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() +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidLookFeelActions.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidLookFeelActions.kt new file mode 100644 index 000000000..081b0a12b --- /dev/null +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidLookFeelActions.kt @@ -0,0 +1,22 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* SPDX-License-Identifier: GPL-3.0-or-later +*/ + +package net.newpipe.app.platform + +import org.koin.core.annotation.Singleton + +@Singleton(binds = [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() +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidUpdateActions.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidUpdateActions.kt new file mode 100644 index 000000000..d9e1211f4 --- /dev/null +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/AndroidUpdateActions.kt @@ -0,0 +1,15 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* SPDX-License-Identifier: GPL-3.0-or-later +*/ + +package net.newpipe.app.platform + +import org.koin.core.annotation.Singleton + +@Singleton(binds = [UpdateActions::class]) +class AndroidUpdateActions( + private val legacyHooks: AndroidLegacyHooks +) : UpdateActions { + override fun runManualCheck() = legacyHooks.runManualUpdateCheck() +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/platform/DefaultAndroidLegacyHooks.kt b/shared/src/androidMain/kotlin/net/newpipe/app/platform/DefaultAndroidLegacyHooks.kt index a193f3348..2b9576f04 100644 --- a/shared/src/androidMain/kotlin/net/newpipe/app/platform/DefaultAndroidLegacyHooks.kt +++ b/shared/src/androidMain/kotlin/net/newpipe/app/platform/DefaultAndroidLegacyHooks.kt @@ -1,4 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package net.newpipe.app.platform -class DefaultAndroidLegacyHooks { -} \ No newline at end of file +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." + ) +} diff --git a/shared/src/commonMain/composeResources/drawable/ic_newpipe_update.xml b/shared/src/commonMain/composeResources/drawable/ic_newpipe_update.xml index e69de29bb..e9aee70b7 100644 --- a/shared/src/commonMain/composeResources/drawable/ic_newpipe_update.xml +++ b/shared/src/commonMain/composeResources/drawable/ic_newpipe_update.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index d56afc50b..929bf1bb0 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -34,4 +34,288 @@ Open in browser Done Not available + + + Settings + Player + Behavior + Downloads + Look and feel + History & cache + Content + Feed + Services + Language + Backup & restore + Updates + Debug + Enable the Redesigned Settings page + LeakCanary + Allow heap dumping + LeakCanary is not available in this build + Show memory leaks + Enable disposed exceptions + Throw on RxJava onErrorNotImplemented after disposal + Show original time-ago + Disable smoothing of upload dates + Show "crash the player" option + Adds an option to the player menu to forcibly crash it + Check new streams now + Crash the app + Show error snackbar + Create error notification + Ask where to download + 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 + Use system folder picker (SAF) + The \'Storage Access Framework\' allows downloads to an external SD card + Video download folder + Downloaded video files are stored here + Audio download folder + Downloaded audio files are stored here + + + Watch history + Keep track of watched videos + Resume playback + Restore last playback position + Positions in lists + Show playback position indicators in lists + Search history + Store search queries locally + Clear data + Wipe cached metadata + Remove all cached webpage data + Clear watch history + Deletes the history of played streams and the playback positions + Delete playback positions + Deletes all playback positions + Clear search history + Deletes history of search keywords + Clear reCAPTCHA cookies + Clear cookies that NewPipe stores when you solve a reCAPTCHA + + + Cancel + Delete + + + Video & Audio + Default resolution + Default popup resolution + Mobile data limit + Show higher resolutions + Only some devices can play 2K/4K videos + Default video format + Default audio format + Prefer original audio + Pick the audio language marked as original by the uploader, when available + Prefer descriptive audio + Pick the audio track that describes what is happening on screen, when available + ExoPlayer settings + Configure ExoPlayer (the underlying video player) + Use external video player + Some formats are unavailable when this is on + Use external audio player + Show \"Play with Kodi\" option + Show an option to play a video via Kodi + Seekbar preview thumbnails + + + Best resolution + 2160p + 1440p + 1080p60 + 1080p + 720p60 + 720p + 480p + 360p + 240p + 144p + + + No limit + Restrict to Wi-Fi + + + MP4 + WebM + 3GP + M4A + WebM (Opus) + OGG (Vorbis) + + + High quality + Low quality + Off + + + Playback load interval size + Change the load interval size on progressive contents. A lower value may speed up their initial loading + 1 KiB + 16 KiB + 64 KiB + 256 KiB + ExoPlayer default + Use ExoPlayer\'s decoder fallback feature + 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 + Disable media tunneling + Disable media tunneling if you experience a black screen or stuttering on video playback. + Always use ExoPlayer\'s video output surface setting workaround + 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 + + + OK + Yes + No + + + Updates + Show a notification to prompt app update when a new version is available + Check for updates + Manually check for new versions + + + Appearance + Theme + Light theme + Dark theme + Black theme + Automatic (device theme) + Night theme + Select your favorite night theme + This option is only available if %s is selected for Theme + You can select your favorite night theme below + Show \"Hold to enqueue\" tip + Show tip when pressing the background or the popup button in video details + Tablet mode + Automatic + On + Off + List view mode + Automatic + List + Grid + Card + Captions + Modify player caption text scale and background styles. Requires app restart to take effect + Main tabs position + Move main tab selector to the bottom + + + Import database + Overrides your current history, subscriptions, playlists and (optionally) settings + Export database + Export history, subscriptions, playlists and settings + Reset settings + Reset all settings to their default values + Resetting all settings will discard all of your preferred settings and restart the app.\n\nAre you sure you want to proceed? + Import subscriptions + Import subscriptions from a previous .json export + Export subscriptions + Export your subscriptions to a .json file + This will override your current setup. + Do you want to also import settings? + Exported + No valid ZIP file + The settings being imported use a vulnerable format deprecated since NewPipe 0.27.0. Make sure the export is from a trusted source. + + + App language + Content + Default content language + Default content country + Content of main page + What tabs are shown on the main page + Channel tabs + What tabs are shown on the channel pages + PeerTube instances + Select your favorite PeerTube instances + Show age restricted content + Show content possibly unsuitable for children because it has an age limit (like 18+) + Turn on YouTube\'s \"Restricted Mode\" + YouTube provides a \"Restricted Mode\" which hides potentially mature content + Search suggestions + Choose the suggestions to show when searching + Image quality + Choose the quality of images and whether to load images at all, to reduce data and memory usage + Low + Medium + High + Show comments + Turn off to hide comments + Show \'Next\' and \'Similar\' videos + Show description + Turn off to hide video description and additional information + Show meta info + Turn off to hide meta info boxes with additional information + + + Feed update threshold + Time after last update before a subscription is considered outdated + Fetch from dedicated feed when available + Available in some services, usually much faster but may return limited info + Fetch channel tabs + Tabs to fetch when updating the feed. No effect if fast mode is used. + + + Immediately + After 5 minutes + After 15 minutes + After 1 hour + After 6 hours + After 12 hours + After 1 day + + + Videos + Tracks + Shorts + Livestreams + Channels + Playlists + Albums + Likes + About + + + Local + Remote + + + System default + English + Spanish + French + German + Portuguese + Russian + Chinese + Japanese + Korean + Italian + Dutch + Polish + Turkish + Arabic + Hindi + + System default + United States + United Kingdom + Germany + France + Spain + Italy + Brazil + Japan + South Korea + India + Russia + China + Australia + Canada + Mexico + diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/App.kt b/shared/src/commonMain/kotlin/net/newpipe/app/App.kt index ae4d54da0..e7aaed9e3 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/App.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/App.kt @@ -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 = emptyList(), withKoin: @Composable () -> Unit = {} ) { KoinApplication( configuration = koinConfiguration( appDeclaration = { modules(navModule()) + modules(platformModules) } ) ) { diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/composable/ConfirmDialog.kt b/shared/src/commonMain/kotlin/net/newpipe/app/composable/ConfirmDialog.kt index f6d2235bb..5c1269c98 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/composable/ConfirmDialog.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/composable/ConfirmDialog.kt @@ -1,2 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 = {} + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/composable/ListPreference.kt b/shared/src/commonMain/kotlin/net/newpipe/app/composable/ListPreference.kt index f6d2235bb..5f85da16e 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/composable/ListPreference.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/composable/ListPreference.kt @@ -1,2 +1,161 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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, + 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 + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/composable/MultiSelectListPreference.kt b/shared/src/commonMain/kotlin/net/newpipe/app/composable/MultiSelectListPreference.kt new file mode 100644 index 000000000..7003bffad --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/composable/MultiSelectListPreference.kt @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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, + selectedValues: Set, + onValuesChange: (Set) -> 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 = {} + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/composable/PreferenceCategoryHeader.kt b/shared/src/commonMain/kotlin/net/newpipe/app/composable/PreferenceCategoryHeader.kt index f6d2235bb..99d5e68c7 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/composable/PreferenceCategoryHeader.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/composable/PreferenceCategoryHeader.kt @@ -1,2 +1,47 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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 `` 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") +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/composable/PreferenceText.kt b/shared/src/commonMain/kotlin/net/newpipe/app/composable/PreferenceText.kt index f6d2235bb..769dcff16 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/composable/PreferenceText.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/composable/PreferenceText.kt @@ -1,2 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. + * 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 + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/composable/SwitchPreference.kt b/shared/src/commonMain/kotlin/net/newpipe/app/composable/SwitchPreference.kt index f6d2235bb..5007f7c00 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/composable/SwitchPreference.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/composable/SwitchPreference.kt @@ -1,2 +1,102 @@ +/* +* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors +* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. +* 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 + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/composable/TextPreference.kt b/shared/src/commonMain/kotlin/net/newpipe/app/composable/TextPreference.kt index f6d2235bb..e1f2bd75c 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/composable/TextPreference.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/composable/TextPreference.kt @@ -1,2 +1,117 @@ +/* +* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors +* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. +* 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 + ) +} + diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Destination.kt b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Destination.kt index bf0d11557..e962be03c 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Destination.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Destination.kt @@ -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 + } + } + diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavModule.kt b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavModule.kt index 4367d50a0..8880b693f 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavModule.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavModule.kt @@ -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 { AboutScreen() } + + navigation { + SettingsHomeScreen() + } + + navigation { + DebugScreen() + } + + navigation { + PlayerSettingsScreen() + } + + navigation { + DownloadSettingsScreen() + } + + navigation { + HistorySettingsScreen() + } + + navigation { + ExoPlayerSettingsScreen() + } + + navigation { + UpdatesSettingsScreen() + } + + navigation { + LookFeelSettingsScreen() + } + + navigation { + BackupRestoreSettingsScreen() + } + + navigation { + ContentSettingsScreen() + } + } /** diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/platform/BackupRestoreActions.kt b/shared/src/commonMain/kotlin/net/newpipe/app/platform/BackupRestoreActions.kt new file mode 100644 index 000000000..71fb12ec5 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/platform/BackupRestoreActions.kt @@ -0,0 +1,32 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/platform/ContentActions.kt b/shared/src/commonMain/kotlin/net/newpipe/app/platform/ContentActions.kt new file mode 100644 index 000000000..f3fe49b18 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/platform/ContentActions.kt @@ -0,0 +1,20 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/platform/DebugActions.kt b/shared/src/commonMain/kotlin/net/newpipe/app/platform/DebugActions.kt index 38452c7f0..57520f339 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/platform/DebugActions.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/platform/DebugActions.kt @@ -1,2 +1,20 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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 +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/platform/DownloadActions.kt b/shared/src/commonMain/kotlin/net/newpipe/app/platform/DownloadActions.kt index 38452c7f0..3f5c607b9 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/platform/DownloadActions.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/platform/DownloadActions.kt @@ -1,2 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/platform/HistoryActions.kt b/shared/src/commonMain/kotlin/net/newpipe/app/platform/HistoryActions.kt index 38452c7f0..a2ccb9669 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/platform/HistoryActions.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/platform/HistoryActions.kt @@ -1,2 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. + * 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 +} + diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/platform/LookFeelActions.kt b/shared/src/commonMain/kotlin/net/newpipe/app/platform/LookFeelActions.kt new file mode 100644 index 000000000..27182bba6 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/platform/LookFeelActions.kt @@ -0,0 +1,40 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/platform/UpdateActions.kt b/shared/src/commonMain/kotlin/net/newpipe/app/platform/UpdateActions.kt new file mode 100644 index 000000000..25c3d5b6c --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/platform/UpdateActions.kt @@ -0,0 +1,16 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/backuprestore/BackupRestoreSettingsScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/backuprestore/BackupRestoreSettingsScreen.kt new file mode 100644 index 000000000..aab4ec6e9 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/backuprestore/BackupRestoreSettingsScreen.kt @@ -0,0 +1,149 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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 = {} + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/content/ContentSettingsScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/content/ContentSettingsScreen.kt new file mode 100644 index 000000000..f3243c674 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/content/ContentSettingsScreen.kt @@ -0,0 +1,516 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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, + showSearchSuggestions: Set, + imageQuality: String, + showAgeRestricted: Boolean, + youtubeRestrictedMode: Boolean, + showComments: Boolean, + showNextVideo: Boolean, + showDescription: Boolean, + showMetaInfo: Boolean, + feedUpdateThreshold: String, + feedUseDedicatedFetch: Boolean, + feedFetchChannelTabs: Set, + onNavigateUp: () -> Unit, + onSetAppLanguage: (String) -> Unit, + onSetContentLanguage: (String) -> Unit, + onSetContentCountry: (String) -> Unit, + onOpenMainPageTabs: () -> Unit, + onSetShowChannelTabs: (Set) -> Unit, + onOpenPeertubeInstances: () -> Unit, + onToggleShowAgeRestricted: (Boolean) -> Unit, + onToggleYoutubeRestrictedMode: (Boolean) -> Unit, + onSetShowSearchSuggestions: (Set) -> Unit, + onSetImageQuality: (String) -> Unit, + onToggleShowComments: (Boolean) -> Unit, + onToggleShowNextVideo: (Boolean) -> Unit, + onToggleShowDescription: (Boolean) -> Unit, + onToggleShowMetaInfo: (Boolean) -> Unit, + onSetFeedUpdateThreshold: (String) -> Unit, + onToggleFeedUseDedicatedFetch: (Boolean) -> Unit, + onSetFeedFetchChannelTabs: (Set) -> 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 = 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 = 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 = + 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 = + listOf( + MultiSelectListPreferenceOption(SUGGESTIONS_LOCAL, stringResource(Res.string.local_search_suggestions)), + MultiSelectListPreferenceOption(SUGGESTIONS_REMOTE, stringResource(Res.string.remote_search_suggestions)) + ) + +@Composable +private fun imageQualityOptions(): List = 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 = 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 = + 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 = {} + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/debug/DebugScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/debug/DebugScreen.kt new file mode 100644 index 000000000..87d4e0dbc --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/debug/DebugScreen.kt @@ -0,0 +1,222 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. + * 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 = {} + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/download/DownloadSettingsScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/download/DownloadSettingsScreen.kt index d25b99233..4ec896360 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/download/DownloadSettingsScreen.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/download/DownloadSettingsScreen.kt @@ -1,2 +1,124 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 = {} + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/exoplayer/ExoPlayerSettingsScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/exoplayer/ExoPlayerSettingsScreen.kt new file mode 100644 index 000000000..f81ae1592 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/exoplayer/ExoPlayerSettingsScreen.kt @@ -0,0 +1,176 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 = 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 = {} + ) +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/history/HistorySettingsScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/history/HistorySettingsScreen.kt index 2c2e57655..0095800b2 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/history/HistorySettingsScreen.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/history/HistorySettingsScreen.kt @@ -1,2 +1,263 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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(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 = {} + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/home/SettingsHomeScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/home/SettingsHomeScreen.kt new file mode 100644 index 000000000..ec02467ba --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/home/SettingsHomeScreen.kt @@ -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, + 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 = 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 = {} + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/lookfeel/LookFeelSettingsScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/lookfeel/LookFeelSettingsScreen.kt new file mode 100644 index 000000000..9cc0f541c --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/lookfeel/LookFeelSettingsScreen.kt @@ -0,0 +1,249 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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 = 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 = listOf( + ListPreferenceOption(THEME_DARK, stringResource(Res.string.theme_dark)), + ListPreferenceOption(THEME_BLACK, stringResource(Res.string.theme_black)) +) + +@Composable +private fun tabletModeOptions(): List = 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 = + 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 = {} + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/player/PlayerSettingsScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/player/PlayerSettingsScreen.kt index 46243ce54..6cef1d4c9 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/player/PlayerSettingsScreen.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/player/PlayerSettingsScreen.kt @@ -1,2 +1,363 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 { + 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 = 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 = 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 = 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 = 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 = {} + ) +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/updates/UpdatesSettingsScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/updates/UpdatesSettingsScreen.kt new file mode 100644 index 000000000..efecb0385 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/settings/updates/UpdatesSettingsScreen.kt @@ -0,0 +1,98 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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 = {} + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/theme/Dimens.kt b/shared/src/commonMain/kotlin/net/newpipe/app/theme/Dimens.kt index b6e43776b..bb550b830 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/theme/Dimens.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/theme/Dimens.kt @@ -22,3 +22,5 @@ val iconHDPI = 72.dp val iconXHDPI = 96.dp val iconXXHDPI = 144.dp val iconXXXHDPI = 192.dp + +val preferenceMinHeight = 48.dp diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/BooleanPreference.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/BooleanPreference.kt index 057764755..51790e132 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/BooleanPreference.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/BooleanPreference.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/SettingsViewModel.kt new file mode 100644 index 000000000..67c45a07e --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/SettingsViewModel.kt @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. + * 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) +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/StringPreference.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/StringPreference.kt index d7463de86..920be52a1 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/StringPreference.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/StringPreference.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/StringSetPreference.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/StringSetPreference.kt new file mode 100644 index 000000000..9c29d11b5 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/StringSetPreference.kt @@ -0,0 +1,52 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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` 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, + private val settings: ObservableSettings, + scope: CoroutineScope +) { + val state: StateFlow> = 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) { + settings.putString(key, newValue.joinToString(",")) + } + + private fun decode(raw: String): Set = + raw.split(",").filter { it.isNotEmpty() }.toSet() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/backuprestore/BackupRestoreSettingsViewModel.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/backuprestore/BackupRestoreSettingsViewModel.kt new file mode 100644 index 000000000..27535a67a --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/backuprestore/BackupRestoreSettingsViewModel.kt @@ -0,0 +1,21 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/content/ContentSettingsViewModel.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/content/ContentSettingsViewModel.kt new file mode 100644 index 000000000..774a3e99b --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/content/ContentSettingsViewModel.kt @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 = appLanguagePref.state + val contentLanguage: StateFlow = contentLanguagePref.state + val contentCountry: StateFlow = contentCountryPref.state + val imageQuality: StateFlow = imageQualityPref.state + val feedUpdateThreshold: StateFlow = 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 = showAgeRestrictedPref.state + val youtubeRestrictedMode: StateFlow = youtubeRestrictedModePref.state + val showComments: StateFlow = showCommentsPref.state + val showNextVideo: StateFlow = showNextVideoPref.state + val showDescription: StateFlow = showDescriptionPref.state + val showMetaInfo: StateFlow = showMetaInfoPref.state + val feedUseDedicatedFetch: StateFlow = 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> = showChannelTabsPref.state + val showSearchSuggestions: StateFlow> = showSearchSuggestionsPref.state + val feedFetchChannelTabs: StateFlow> = feedFetchChannelTabsPref.state + + fun setShowChannelTabs(values: Set) = showChannelTabsPref.set(values) + fun setShowSearchSuggestions(values: Set) = showSearchSuggestionsPref.set(values) + fun setFeedFetchChannelTabs(values: Set) = feedFetchChannelTabsPref.set(values) + + // Sub-screen launchers + fun openMainPageTabs() = contentActions.openMainPageTabsChooser() + fun openPeertubeInstances() = contentActions.openPeertubeInstanceList() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/debug/DebugSettingsViewModel.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/debug/DebugSettingsViewModel.kt new file mode 100644 index 000000000..f1ae9fdd5 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/debug/DebugSettingsViewModel.kt @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. + * 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() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/download/DownloadSettingsViewModel.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/download/DownloadSettingsViewModel.kt index abf44adab..aba3f328f 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/download/DownloadSettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/download/DownloadSettingsViewModel.kt @@ -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 = _videoPath.asStateFlow() - val audioPath: StateFlow = _audioPath.asStateFlow() + val storageAsk: StateFlow = askPref.state + val useSaf: StateFlow = useSafPref.state + val videoPath: StateFlow = videoPathPref.state + val audioPath: StateFlow = 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) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/exoplayer/ExoPlayerSettingsViewModel.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/exoplayer/ExoPlayerSettingsViewModel.kt new file mode 100644 index 000000000..318cf3f6c --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/exoplayer/ExoPlayerSettingsViewModel.kt @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 = progressiveLoadIntervalPref.state + val useDecoderFallback: StateFlow = useDecoderFallbackPref.state + val disableMediaTunneling: StateFlow = disableMediaTunnelingPref.state + val alwaysUseSetOutputSurfaceWorkaround: StateFlow = + 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) +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/history/HistorySettingsViewModel.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/history/HistorySettingsViewModel.kt index e65564495..7f408c343 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/history/HistorySettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/history/HistorySettingsViewModel.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/lookfeel/LookFeelSettingsViewModel.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/lookfeel/LookFeelSettingsViewModel.kt new file mode 100644 index 000000000..7a670fe9a --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/lookfeel/LookFeelSettingsViewModel.kt @@ -0,0 +1,97 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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 = themePref.state + val nightTheme: StateFlow = nightThemePref.state + val showHoldToAppend: StateFlow = + showHoldToAppendPref.state + val tabletMode: StateFlow = tabletModePref.state + val listViewMode: StateFlow = listViewModePref.state + val mainTabsPosition: StateFlow = + 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() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/player/PlayerSettingsViewModel.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/player/PlayerSettingsViewModel.kt index 24398a7fe..3f4f8b061 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/player/PlayerSettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/player/PlayerSettingsViewModel.kt @@ -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 diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/updates/UpdatesSettingsViewModel.kt b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/updates/UpdatesSettingsViewModel.kt new file mode 100644 index 000000000..9980796de --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/viewmodel/settings/updates/UpdatesSettingsViewModel.kt @@ -0,0 +1,37 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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() +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt b/shared/src/iosMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt index b5f7ef3e3..73e543873 100644 --- a/shared/src/iosMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt +++ b/shared/src/iosMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt @@ -1,17 +1,16 @@ /* - * SPDX-FileCopyrightText: 2026 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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()) \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSBackupRestoreActions.kt b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSBackupRestoreActions.kt new file mode 100644 index 000000000..3774233ab --- /dev/null +++ b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSBackupRestoreActions.kt @@ -0,0 +1,20 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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") +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSContentActions.kt b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSContentActions.kt new file mode 100644 index 000000000..35034c620 --- /dev/null +++ b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSContentActions.kt @@ -0,0 +1,20 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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") +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSDebugActions.kt b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSDebugActions.kt index 38452c7f0..fbb299f04 100644 --- a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSDebugActions.kt +++ b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSDebugActions.kt @@ -1,2 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package net.newpipe.app.platform +import org.koin.core.annotation.Singleton + +@Singleton(binds = [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 +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSDownloadActions.kt b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSDownloadActions.kt index 38452c7f0..168ce9342 100644 --- a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSDownloadActions.kt +++ b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSDownloadActions.kt @@ -1,2 +1,20 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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") + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSHistoryActions.kt b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSHistoryActions.kt index 38452c7f0..a082c9392 100644 --- a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSHistoryActions.kt +++ b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSHistoryActions.kt @@ -1,2 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 +} + diff --git a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSLookFeelActions.kt b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSLookFeelActions.kt new file mode 100644 index 000000000..e2c4b3ad4 --- /dev/null +++ b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSLookFeelActions.kt @@ -0,0 +1,25 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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") + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSUpdateActions.kt b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSUpdateActions.kt new file mode 100644 index 000000000..4b89c9578 --- /dev/null +++ b/shared/src/iosMain/kotlin/net/newpipe/app/platform/IOSUpdateActions.kt @@ -0,0 +1,16 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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") + } +} \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt b/shared/src/jvmMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt index 82a7d426f..d2e5394c5 100644 --- a/shared/src/jvmMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt +++ b/shared/src/jvmMain/kotlin/net/newpipe/app/di/settings/SettingsModule.kt @@ -1,17 +1,16 @@ /* - * SPDX-FileCopyrightText: 2026 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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()) \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMBackupRestoreActions.kt b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMBackupRestoreActions.kt new file mode 100644 index 000000000..e441fec54 --- /dev/null +++ b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMBackupRestoreActions.kt @@ -0,0 +1,20 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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") +} \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMContentActions.kt b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMContentActions.kt new file mode 100644 index 000000000..73a9fc611 --- /dev/null +++ b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMContentActions.kt @@ -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") +} \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMDebugActions.kt b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMDebugActions.kt index 38452c7f0..7764cce95 100644 --- a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMDebugActions.kt +++ b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMDebugActions.kt @@ -1,2 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package net.newpipe.app.platform +import org.koin.core.annotation.Singleton + +@Singleton(binds = [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 +} \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMDownloadActions.kt b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMDownloadActions.kt index 38452c7f0..1f4bb499d 100644 --- a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMDownloadActions.kt +++ b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMDownloadActions.kt @@ -1,2 +1,33 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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) + } + } +} diff --git a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMHistoryActions.kt b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMHistoryActions.kt index 38452c7f0..09cd9816b 100644 --- a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMHistoryActions.kt +++ b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMHistoryActions.kt @@ -1,2 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * 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 +} \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMLookFeelActions.kt b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMLookFeelActions.kt new file mode 100644 index 000000000..213aedd0f --- /dev/null +++ b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMLookFeelActions.kt @@ -0,0 +1,25 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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") + } +} \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMUpdateActions.kt b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMUpdateActions.kt new file mode 100644 index 000000000..3875961c0 --- /dev/null +++ b/shared/src/jvmMain/kotlin/net/newpipe/app/platform/JVMUpdateActions.kt @@ -0,0 +1,16 @@ +/* +* SPDX-FileCopyrightText: 2026 NewPipe e.V. +* 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") + } +} \ No newline at end of file