update the video-audio screen viewmodel, and tests

This commit is contained in:
Ida Delphine
2026-10-07 16:59:43 +08:00
committed by Aayush Gupta
parent 62784f3cad
commit 561afbf5a6
5 changed files with 507 additions and 85 deletions
@@ -58,7 +58,7 @@
<string name="night_theme_summary">Select your favorite night theme — %1$s</string>
<string name="night_theme_available">This option is only available if %1$s is selected for Theme</string>
<string name="select_night_theme_toast">You can select your favorite night theme below</string>
<string name="show_hold_to_append_title">Show \"Hold to enqueue\" tip</string>
<string name="show_hold_to_append_title">Show "Hold to enqueue" tip</string>
<string name="show_hold_to_append_summary">Show tip when pressing the background or the popup button in video \"Details:\"</string>
<string name="tablet_mode_title">Tablet mode</string>
<string name="list_view_mode">List view mode</string>
@@ -94,7 +94,7 @@
<string name="use_external_video_player_title">Use external video player</string>
<string name="use_external_video_player_summary">Removes audio at some resolutions</string>
<string name="use_external_audio_player_title">Use external audio player</string>
<string name="show_play_with_kodi_title">Show \"Play with Kodi\" option</string>
<string name="show_play_with_kodi_title">Show "Play with Kodi" option</string>
<string name="show_play_with_kodi_summary">Display an option to play a video via Kodi media center</string>
<string name="seekbar_preview_thumbnail_title">Seekbar thumbnail preview</string>
<string name="high_quality_larger">High quality (larger)</string>
@@ -103,7 +103,7 @@
<!-- Video/Audio Settings: Behavior section -->
<string name="settings_category_player_behavior_title">Behavior</string>
<string name="preferred_open_action_settings_title">Preferred \'open\' action</string>
<string name="preferred_open_action_settings_title">Preferred 'open' action</string>
<string name="preferred_open_action_settings_summary">Default action when opening content — %1$s</string>
<string name="show_info">Show info</string>
<string name="video_player">Video player</string>
@@ -1,2 +1,36 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package net.newpipe.app.composable
import androidx.compose.foundation.layout.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 androidx.compose.ui.unit.dp
import net.newpipe.app.preview.ThemePreviewProvider
/**
* Section header inside a settings screen
*/
@Composable
fun PreferenceCategoryTitle(title: String, modifier: Modifier = Modifier) {
Text(
text = title,
modifier = modifier.padding(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 8.dp),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall
)
}
@PreviewWrapper(ThemePreviewProvider::class)
@PreviewLightDark
@Composable
private fun PreferenceCategoryTitlePreview() {
PreferenceCategoryTitle(title = "Player")
}
@@ -14,15 +14,20 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import net.newpipe.app.composable.ListPreference
import net.newpipe.app.composable.ListPreferenceEntry
import net.newpipe.app.composable.PreferenceCategoryTitle
import net.newpipe.app.composable.SwitchPreference
import net.newpipe.app.composable.TopAppBar
import net.newpipe.app.navigation.Navigator
@@ -30,20 +35,77 @@ import net.newpipe.app.preferences.VideoAudioPreferences
import net.newpipe.app.preview.ThemePreviewProvider
import net.newpipe.app.viewmodel.settings.VideoAudioSettingsViewModel
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.add_to_playlist
import newpipe.shared.generated.resources.always
import newpipe.shared.generated.resources.always_ask_open_action
import newpipe.shared.generated.resources.auto_queue_summary
import newpipe.shared.generated.resources.auto_queue_title
import newpipe.shared.generated.resources.autoplay_summary
import newpipe.shared.generated.resources.autoplay_title
import newpipe.shared.generated.resources.background_player
import newpipe.shared.generated.resources.best_resolution
import newpipe.shared.generated.resources.brightness
import newpipe.shared.generated.resources.clear_queue_confirmation_summary
import newpipe.shared.generated.resources.clear_queue_confirmation_title
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.dont_show
import newpipe.shared.generated.resources.download
import newpipe.shared.generated.resources.enqueue_stream
import newpipe.shared.generated.resources.high_quality_larger
import newpipe.shared.generated.resources.ignore_hardware_media_buttons_summary
import newpipe.shared.generated.resources.ignore_hardware_media_buttons_title
import newpipe.shared.generated.resources.left_gesture_control_summary
import newpipe.shared.generated.resources.left_gesture_control_title
import newpipe.shared.generated.resources.limit_data_usage_none_description
import newpipe.shared.generated.resources.limit_mobile_data_usage_title
import newpipe.shared.generated.resources.low_quality_smaller
import newpipe.shared.generated.resources.minimize_on_exit_background_description
import newpipe.shared.generated.resources.minimize_on_exit_none_description
import newpipe.shared.generated.resources.minimize_on_exit_popup_description
import newpipe.shared.generated.resources.minimize_on_exit_summary
import newpipe.shared.generated.resources.minimize_on_exit_title
import newpipe.shared.generated.resources.never
import newpipe.shared.generated.resources.new_seek_duration_toast
import newpipe.shared.generated.resources.none
import newpipe.shared.generated.resources.popup_player
import newpipe.shared.generated.resources.popup_remember_size_pos_summary
import newpipe.shared.generated.resources.popup_remember_size_pos_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.preferred_open_action_settings_summary
import newpipe.shared.generated.resources.preferred_open_action_settings_title
import newpipe.shared.generated.resources.resume_on_audio_focus_gain_summary
import newpipe.shared.generated.resources.resume_on_audio_focus_gain_title
import newpipe.shared.generated.resources.right_gesture_control_summary
import newpipe.shared.generated.resources.right_gesture_control_title
import newpipe.shared.generated.resources.seconds
import newpipe.shared.generated.resources.seek_duration_title
import newpipe.shared.generated.resources.seekbar_preview_thumbnail_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_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_info
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.start_main_player_fullscreen_summary
import newpipe.shared.generated.resources.start_main_player_fullscreen_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.use_inexact_seek_summary
import newpipe.shared.generated.resources.use_inexact_seek_title
import newpipe.shared.generated.resources.video_player
import newpipe.shared.generated.resources.volume
import newpipe.shared.generated.resources.wifi_only
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@@ -57,16 +119,38 @@ fun VideoAudioSettingsScreen(
val defaultPopupResolution by viewModel.defaultPopupResolution.collectAsStateWithLifecycle()
val mobileDataResolution by viewModel.mobileDataResolution.collectAsStateWithLifecycle()
val showHigherResolutions by viewModel.showHigherResolutions.collectAsStateWithLifecycle()
val videoFormat by viewModel.videoFormat.collectAsStateWithLifecycle()
val audioFormat by viewModel.audioFormat.collectAsStateWithLifecycle()
val preferOriginalAudio by viewModel.preferOriginalAudio.collectAsStateWithLifecycle()
val preferDescriptiveAudio by viewModel.preferDescriptiveAudio.collectAsStateWithLifecycle()
val useExternalVideoPlayer by viewModel.useExternalVideoPlayer.collectAsStateWithLifecycle()
val useExternalAudioPlayer by viewModel.useExternalAudioPlayer.collectAsStateWithLifecycle()
val showPlayWithKodi by viewModel.showPlayWithKodi.collectAsStateWithLifecycle()
val seekbarPreviewThumbnail by viewModel.seekbarPreviewThumbnail.collectAsStateWithLifecycle()
val preferredOpenAction by viewModel.preferredOpenAction.collectAsStateWithLifecycle()
val minimizeOnExit by viewModel.minimizeOnExit.collectAsStateWithLifecycle()
val startMainPlayerFullscreen by viewModel.startMainPlayerFullscreen.collectAsStateWithLifecycle()
val autoplay by viewModel.autoplay.collectAsStateWithLifecycle()
val autoQueue by viewModel.autoQueue.collectAsStateWithLifecycle()
val resumeOnAudioFocusGain by viewModel.resumeOnAudioFocusGain.collectAsStateWithLifecycle()
val leftGestureControl by viewModel.leftGestureControl.collectAsStateWithLifecycle()
val rightGestureControl by viewModel.rightGestureControl.collectAsStateWithLifecycle()
val popupRememberSizePos by viewModel.popupRememberSizePos.collectAsStateWithLifecycle()
val useInexactSeek by viewModel.useInexactSeek.collectAsStateWithLifecycle()
val seekDuration by viewModel.seekDuration.collectAsStateWithLifecycle()
val clearQueueConfirmation by viewModel.clearQueueConfirmation.collectAsStateWithLifecycle()
val ignoreHardwareMediaButtons by viewModel.ignoreHardwareMediaButtons.collectAsStateWithLifecycle()
// Option lists are pure functions of their toggles, so derive them instead of storing them.
val resolutionValues = remember(showHigherResolutions) {
VideoAudioPreferences.resolutions(showHigherResolutions)
}
val mobileDataResolutionValues = remember(showHigherResolutions) {
VideoAudioPreferences.mobileDataResolutions(showHigherResolutions)
}
val videoFormat by viewModel.videoFormat.collectAsStateWithLifecycle()
val audioFormat by viewModel.audioFormat.collectAsStateWithLifecycle()
val preferOriginalAudio by viewModel.preferOriginalAudio.collectAsStateWithLifecycle()
val preferDescriptiveAudio by viewModel.preferDescriptiveAudio.collectAsStateWithLifecycle()
val seekDurationValues = remember(useInexactSeek) {
VideoAudioPreferences.seekDurationsMs(useInexactSeek)
}
VideoAudioSettingsScreenContent(
defaultResolution = defaultResolution,
@@ -79,6 +163,24 @@ fun VideoAudioSettingsScreen(
audioFormat = audioFormat,
preferOriginalAudio = preferOriginalAudio,
preferDescriptiveAudio = preferDescriptiveAudio,
useExternalVideoPlayer = useExternalVideoPlayer,
useExternalAudioPlayer = useExternalAudioPlayer,
showPlayWithKodi = showPlayWithKodi,
seekbarPreviewThumbnail = seekbarPreviewThumbnail,
preferredOpenAction = preferredOpenAction,
minimizeOnExit = minimizeOnExit,
startMainPlayerFullscreen = startMainPlayerFullscreen,
autoplay = autoplay,
autoQueue = autoQueue,
resumeOnAudioFocusGain = resumeOnAudioFocusGain,
leftGestureControl = leftGestureControl,
rightGestureControl = rightGestureControl,
popupRememberSizePos = popupRememberSizePos,
useInexactSeek = useInexactSeek,
seekDuration = seekDuration,
seekDurationValues = seekDurationValues,
clearQueueConfirmation = clearQueueConfirmation,
ignoreHardwareMediaButtons = ignoreHardwareMediaButtons,
onDefaultResolutionChange = viewModel::setDefaultResolution,
onDefaultPopupResolutionChange = viewModel::setDefaultPopupResolution,
onMobileDataResolutionChange = viewModel::setMobileDataResolution,
@@ -87,6 +189,23 @@ fun VideoAudioSettingsScreen(
onAudioFormatChange = viewModel::setAudioFormat,
onPreferOriginalAudioChange = viewModel::setPreferOriginalAudio,
onPreferDescriptiveAudioChange = viewModel::setPreferDescriptiveAudio,
onUseExternalVideoPlayerChange = viewModel::setUseExternalVideoPlayer,
onUseExternalAudioPlayerChange = viewModel::setUseExternalAudioPlayer,
onShowPlayWithKodiChange = viewModel::setShowPlayWithKodi,
onSeekbarPreviewThumbnailChange = viewModel::setSeekbarPreviewThumbnail,
onPreferredOpenActionChange = viewModel::setPreferredOpenAction,
onMinimizeOnExitChange = viewModel::setMinimizeOnExit,
onStartMainPlayerFullscreenChange = viewModel::setStartMainPlayerFullscreen,
onAutoplayChange = viewModel::setAutoplay,
onAutoQueueChange = viewModel::setAutoQueue,
onResumeOnAudioFocusGainChange = viewModel::setResumeOnAudioFocusGain,
onLeftGestureControlChange = viewModel::setLeftGestureControl,
onRightGestureControlChange = viewModel::setRightGestureControl,
onPopupRememberSizePosChange = viewModel::setPopupRememberSizePos,
onUseInexactSeekChange = viewModel::setUseInexactSeek,
onSeekDurationChange = viewModel::setSeekDuration,
onClearQueueConfirmationChange = viewModel::setClearQueueConfirmation,
onIgnoreHardwareMediaButtonsChange = viewModel::setIgnoreHardwareMediaButtons,
onNavigateUp = { navigator.navigateUp() }
)
}
@@ -103,6 +222,24 @@ fun VideoAudioSettingsScreenContent(
audioFormat: String = VideoAudioPreferences.DEFAULT_AUDIO_FORMAT,
preferOriginalAudio: Boolean = VideoAudioPreferences.DEFAULT_PREFER_ORIGINAL_AUDIO,
preferDescriptiveAudio: Boolean = VideoAudioPreferences.DEFAULT_PREFER_DESCRIPTIVE_AUDIO,
useExternalVideoPlayer: Boolean = VideoAudioPreferences.DEFAULT_USE_EXTERNAL_VIDEO_PLAYER,
useExternalAudioPlayer: Boolean = VideoAudioPreferences.DEFAULT_USE_EXTERNAL_AUDIO_PLAYER,
showPlayWithKodi: Boolean = VideoAudioPreferences.DEFAULT_SHOW_PLAY_WITH_KODI,
seekbarPreviewThumbnail: String = VideoAudioPreferences.DEFAULT_SEEKBAR_PREVIEW_THUMBNAIL,
preferredOpenAction: String = VideoAudioPreferences.DEFAULT_PREFERRED_OPEN_ACTION,
minimizeOnExit: String = VideoAudioPreferences.DEFAULT_MINIMIZE_ON_EXIT,
startMainPlayerFullscreen: Boolean = VideoAudioPreferences.DEFAULT_START_MAIN_PLAYER_FULLSCREEN,
autoplay: String = VideoAudioPreferences.DEFAULT_AUTOPLAY,
autoQueue: Boolean = VideoAudioPreferences.DEFAULT_AUTO_QUEUE,
resumeOnAudioFocusGain: Boolean = VideoAudioPreferences.DEFAULT_RESUME_ON_AUDIO_FOCUS_GAIN,
leftGestureControl: String = VideoAudioPreferences.DEFAULT_LEFT_GESTURE_CONTROL,
rightGestureControl: String = VideoAudioPreferences.DEFAULT_RIGHT_GESTURE_CONTROL,
popupRememberSizePos: Boolean = VideoAudioPreferences.DEFAULT_POPUP_REMEMBER_SIZE_POS,
useInexactSeek: Boolean = VideoAudioPreferences.DEFAULT_USE_INEXACT_SEEK,
seekDuration: String = VideoAudioPreferences.DEFAULT_SEEK_DURATION_MS,
seekDurationValues: List<String> = VideoAudioPreferences.SEEK_DURATIONS_MS,
clearQueueConfirmation: Boolean = VideoAudioPreferences.DEFAULT_CLEAR_QUEUE_CONFIRMATION,
ignoreHardwareMediaButtons: Boolean = VideoAudioPreferences.DEFAULT_IGNORE_HARDWARE_MEDIA_BUTTONS,
onDefaultResolutionChange: (String) -> Unit = {},
onDefaultPopupResolutionChange: (String) -> Unit = {},
onMobileDataResolutionChange: (String) -> Unit = {},
@@ -111,8 +248,28 @@ fun VideoAudioSettingsScreenContent(
onAudioFormatChange: (String) -> Unit = {},
onPreferOriginalAudioChange: (Boolean) -> Unit = {},
onPreferDescriptiveAudioChange: (Boolean) -> Unit = {},
onUseExternalVideoPlayerChange: (Boolean) -> Unit = {},
onUseExternalAudioPlayerChange: (Boolean) -> Unit = {},
onShowPlayWithKodiChange: (Boolean) -> Unit = {},
onSeekbarPreviewThumbnailChange: (String) -> Unit = {},
onPreferredOpenActionChange: (String) -> Unit = {},
onMinimizeOnExitChange: (String) -> Unit = {},
onStartMainPlayerFullscreenChange: (Boolean) -> Unit = {},
onAutoplayChange: (String) -> Unit = {},
onAutoQueueChange: (Boolean) -> Unit = {},
onResumeOnAudioFocusGainChange: (Boolean) -> Unit = {},
onLeftGestureControlChange: (String) -> Unit = {},
onRightGestureControlChange: (String) -> Unit = {},
onPopupRememberSizePosChange: (Boolean) -> Unit = {},
onUseInexactSeekChange: (Boolean) -> Unit = {},
onSeekDurationChange: (String) -> Unit = {},
onClearQueueConfirmationChange: (Boolean) -> Unit = {},
onIgnoreHardwareMediaButtonsChange: (Boolean) -> Unit = {},
onNavigateUp: () -> Unit = {}
) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val bestResolutionLabel = stringResource(Res.string.best_resolution)
val noLimitLabel = stringResource(Res.string.limit_data_usage_none_description)
@@ -126,6 +283,8 @@ fun VideoAudioSettingsScreenContent(
title = if (value == VideoAudioPreferences.LIMIT_DATA_USAGE_NONE) noLimitLabel else value
)
fun selectedTitle(entries: List<ListPreferenceEntry>, value: String) = entries.firstOrNull { it.value == value }?.title.orEmpty()
val resolutionEntries = resolutionValues.map(::resolutionEntry)
val mobileDataEntries = mobileDataResolutionValues.map(::mobileDataEntry)
val videoFormatEntries = VideoAudioPreferences.VIDEO_FORMATS.map { (value, label) ->
@@ -134,6 +293,40 @@ fun VideoAudioSettingsScreenContent(
val audioFormatEntries = VideoAudioPreferences.AUDIO_FORMATS.map { (value, label) ->
ListPreferenceEntry(value, label)
}
val seekbarThumbnailEntries = listOf(
ListPreferenceEntry(VideoAudioPreferences.SEEKBAR_THUMBNAIL_HIGH_QUALITY, stringResource(Res.string.high_quality_larger)),
ListPreferenceEntry(VideoAudioPreferences.SEEKBAR_THUMBNAIL_LOW_QUALITY, stringResource(Res.string.low_quality_smaller)),
ListPreferenceEntry(VideoAudioPreferences.SEEKBAR_THUMBNAIL_NONE, stringResource(Res.string.dont_show))
)
val openActionEntries = listOf(
ListPreferenceEntry(VideoAudioPreferences.OPEN_ACTION_SHOW_INFO, stringResource(Res.string.show_info)),
ListPreferenceEntry(VideoAudioPreferences.OPEN_ACTION_VIDEO_PLAYER, stringResource(Res.string.video_player)),
ListPreferenceEntry(VideoAudioPreferences.OPEN_ACTION_BACKGROUND_PLAYER, stringResource(Res.string.background_player)),
ListPreferenceEntry(VideoAudioPreferences.OPEN_ACTION_POPUP_PLAYER, stringResource(Res.string.popup_player)),
ListPreferenceEntry(VideoAudioPreferences.OPEN_ACTION_DOWNLOAD, stringResource(Res.string.download)),
ListPreferenceEntry(VideoAudioPreferences.OPEN_ACTION_ADD_TO_PLAYLIST, stringResource(Res.string.add_to_playlist)),
ListPreferenceEntry(VideoAudioPreferences.OPEN_ACTION_ENQUEUE, stringResource(Res.string.enqueue_stream)),
ListPreferenceEntry(VideoAudioPreferences.OPEN_ACTION_ALWAYS_ASK, stringResource(Res.string.always_ask_open_action))
)
val minimizeOnExitEntries = listOf(
ListPreferenceEntry(VideoAudioPreferences.MINIMIZE_ON_EXIT_NONE, stringResource(Res.string.minimize_on_exit_none_description)),
ListPreferenceEntry(VideoAudioPreferences.MINIMIZE_ON_EXIT_BACKGROUND, stringResource(Res.string.minimize_on_exit_background_description)),
ListPreferenceEntry(VideoAudioPreferences.MINIMIZE_ON_EXIT_POPUP, stringResource(Res.string.minimize_on_exit_popup_description))
)
val autoplayEntries = listOf(
ListPreferenceEntry(VideoAudioPreferences.AUTOPLAY_ALWAYS, stringResource(Res.string.always)),
ListPreferenceEntry(VideoAudioPreferences.AUTOPLAY_WIFI, stringResource(Res.string.wifi_only)),
ListPreferenceEntry(VideoAudioPreferences.AUTOPLAY_NEVER, stringResource(Res.string.never))
)
val gestureEntries = listOf(
ListPreferenceEntry(VideoAudioPreferences.GESTURE_BRIGHTNESS, stringResource(Res.string.brightness)),
ListPreferenceEntry(VideoAudioPreferences.GESTURE_VOLUME, stringResource(Res.string.volume)),
ListPreferenceEntry(VideoAudioPreferences.GESTURE_NONE, stringResource(Res.string.none))
)
val seekDurationEntries = seekDurationValues.map { value ->
val seconds = VideoAudioPreferences.seekDurationSeconds(value)
ListPreferenceEntry(value, pluralStringResource(Res.plurals.seconds, seconds, seconds))
}
Scaffold(
topBar = {
@@ -141,7 +334,8 @@ fun VideoAudioSettingsScreenContent(
title = stringResource(Res.string.settings_category_video_audio_title),
onNavigateUp = onNavigateUp
)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
Column(
modifier = Modifier
@@ -198,6 +392,140 @@ fun VideoAudioSettingsScreenContent(
checked = preferDescriptiveAudio,
onCheckedChange = onPreferDescriptiveAudioChange
)
// TODO: ExoPlayer settings sub-screen link goes here once that screen is migrated.
PreferenceCategoryTitle(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),
checked = useExternalVideoPlayer,
onCheckedChange = onUseExternalVideoPlayerChange
)
SwitchPreference(
title = stringResource(Res.string.use_external_audio_player_title),
checked = useExternalAudioPlayer,
onCheckedChange = onUseExternalAudioPlayerChange
)
SwitchPreference(
title = stringResource(Res.string.show_play_with_kodi_title),
summary = stringResource(Res.string.show_play_with_kodi_summary),
checked = showPlayWithKodi,
onCheckedChange = onShowPlayWithKodiChange
)
ListPreference(
title = stringResource(Res.string.seekbar_preview_thumbnail_title),
entries = seekbarThumbnailEntries,
selectedValue = seekbarPreviewThumbnail,
onValueSelected = onSeekbarPreviewThumbnailChange
)
PreferenceCategoryTitle(title = stringResource(Res.string.settings_category_player_behavior_title))
ListPreference(
title = stringResource(Res.string.preferred_open_action_settings_title),
entries = openActionEntries,
selectedValue = preferredOpenAction,
onValueSelected = onPreferredOpenActionChange,
summary = stringResource(
Res.string.preferred_open_action_settings_summary,
selectedTitle(openActionEntries, preferredOpenAction)
)
)
// TODO(follow-up): when popup is selected without draw-over-apps permission, the
// legacy screen shows a snackbar linking to the system permission settings. Needs a
// platform actions interface (like AppearanceActions) and is deliberately deferred.
ListPreference(
title = stringResource(Res.string.minimize_on_exit_title),
entries = minimizeOnExitEntries,
selectedValue = minimizeOnExit,
onValueSelected = onMinimizeOnExitChange,
summary = stringResource(
Res.string.minimize_on_exit_summary,
selectedTitle(minimizeOnExitEntries, minimizeOnExit)
)
)
SwitchPreference(
title = stringResource(Res.string.start_main_player_fullscreen_title),
summary = stringResource(Res.string.start_main_player_fullscreen_summary),
checked = startMainPlayerFullscreen,
onCheckedChange = onStartMainPlayerFullscreenChange
)
ListPreference(
title = stringResource(Res.string.autoplay_title),
entries = autoplayEntries,
selectedValue = autoplay,
onValueSelected = onAutoplayChange,
summary = stringResource(
Res.string.autoplay_summary,
selectedTitle(autoplayEntries, autoplay)
)
)
SwitchPreference(
title = stringResource(Res.string.auto_queue_title),
summary = stringResource(Res.string.auto_queue_summary),
checked = autoQueue,
onCheckedChange = onAutoQueueChange
)
SwitchPreference(
title = stringResource(Res.string.resume_on_audio_focus_gain_title),
summary = stringResource(Res.string.resume_on_audio_focus_gain_summary),
checked = resumeOnAudioFocusGain,
onCheckedChange = onResumeOnAudioFocusGainChange
)
ListPreference(
title = stringResource(Res.string.left_gesture_control_title),
entries = gestureEntries,
selectedValue = leftGestureControl,
onValueSelected = onLeftGestureControlChange,
summary = stringResource(Res.string.left_gesture_control_summary)
)
ListPreference(
title = stringResource(Res.string.right_gesture_control_title),
entries = gestureEntries,
selectedValue = rightGestureControl,
onValueSelected = onRightGestureControlChange,
summary = stringResource(Res.string.right_gesture_control_summary)
)
SwitchPreference(
title = stringResource(Res.string.popup_remember_size_pos_title),
summary = stringResource(Res.string.popup_remember_size_pos_summary),
checked = popupRememberSizePos,
onCheckedChange = onPopupRememberSizePosChange
)
SwitchPreference(
title = stringResource(Res.string.use_inexact_seek_title),
summary = stringResource(Res.string.use_inexact_seek_summary),
checked = useInexactSeek,
onCheckedChange = { checked ->
// The view model applies the same adjustment. this only decides whether to
// tell the user about it, mirroring the legacy toast.
VideoAudioPreferences.adjustedSeekDurationMs(seekDuration, checked)?.let { adjustedMs ->
val newSeconds = VideoAudioPreferences.seekDurationSeconds(adjustedMs)
scope.launch {
snackbarHostState.showSnackbar(getString(Res.string.new_seek_duration_toast, newSeconds))
}
}
onUseInexactSeekChange(checked)
}
)
ListPreference(
title = stringResource(Res.string.seek_duration_title),
entries = seekDurationEntries,
selectedValue = seekDuration,
onValueSelected = onSeekDurationChange
)
SwitchPreference(
title = stringResource(Res.string.clear_queue_confirmation_title),
summary = stringResource(Res.string.clear_queue_confirmation_summary),
checked = clearQueueConfirmation,
onCheckedChange = onClearQueueConfirmationChange
)
SwitchPreference(
title = stringResource(Res.string.ignore_hardware_media_buttons_title),
summary = stringResource(Res.string.ignore_hardware_media_buttons_summary),
checked = ignoreHardwareMediaButtons,
onCheckedChange = onIgnoreHardwareMediaButtonsChange
)
}
}
}
@@ -16,87 +16,88 @@ import org.koin.core.annotation.KoinViewModel
class VideoAudioSettingsViewModel(private val settings: Settings) : ViewModel() {
val defaultResolution: StateFlow<String>
field = MutableStateFlow(
settings.getString(
VideoAudioPreferences.KEY_DEFAULT_RESOLUTION,
VideoAudioPreferences.DEFAULT_RESOLUTION
)
)
field = stringFlow(VideoAudioPreferences.KEY_DEFAULT_RESOLUTION, VideoAudioPreferences.DEFAULT_RESOLUTION)
val defaultPopupResolution: StateFlow<String>
field = MutableStateFlow(
settings.getString(
VideoAudioPreferences.KEY_DEFAULT_POPUP_RESOLUTION,
VideoAudioPreferences.DEFAULT_POPUP_RESOLUTION
)
)
field = stringFlow(VideoAudioPreferences.KEY_DEFAULT_POPUP_RESOLUTION, VideoAudioPreferences.DEFAULT_POPUP_RESOLUTION)
val mobileDataResolution: StateFlow<String>
field = MutableStateFlow(
settings.getString(
VideoAudioPreferences.KEY_LIMIT_MOBILE_DATA_USAGE,
VideoAudioPreferences.DEFAULT_LIMIT_MOBILE_DATA_USAGE
)
)
field = stringFlow(VideoAudioPreferences.KEY_LIMIT_MOBILE_DATA_USAGE, VideoAudioPreferences.DEFAULT_LIMIT_MOBILE_DATA_USAGE)
val showHigherResolutions: StateFlow<Boolean>
field = MutableStateFlow(
settings.getBoolean(
VideoAudioPreferences.KEY_SHOW_HIGHER_RESOLUTIONS,
VideoAudioPreferences.DEFAULT_SHOW_HIGHER_RESOLUTIONS
)
)
field = booleanFlow(VideoAudioPreferences.KEY_SHOW_HIGHER_RESOLUTIONS, VideoAudioPreferences.DEFAULT_SHOW_HIGHER_RESOLUTIONS)
val videoFormat: StateFlow<String>
field = MutableStateFlow(
settings.getString(
VideoAudioPreferences.KEY_DEFAULT_VIDEO_FORMAT,
VideoAudioPreferences.DEFAULT_VIDEO_FORMAT
)
)
field = stringFlow(VideoAudioPreferences.KEY_DEFAULT_VIDEO_FORMAT, VideoAudioPreferences.DEFAULT_VIDEO_FORMAT)
val audioFormat: StateFlow<String>
field = MutableStateFlow(
settings.getString(
VideoAudioPreferences.KEY_DEFAULT_AUDIO_FORMAT,
VideoAudioPreferences.DEFAULT_AUDIO_FORMAT
)
)
field = stringFlow(VideoAudioPreferences.KEY_DEFAULT_AUDIO_FORMAT, VideoAudioPreferences.DEFAULT_AUDIO_FORMAT)
val preferOriginalAudio: StateFlow<Boolean>
field = MutableStateFlow(
settings.getBoolean(
VideoAudioPreferences.KEY_PREFER_ORIGINAL_AUDIO,
VideoAudioPreferences.DEFAULT_PREFER_ORIGINAL_AUDIO
)
)
field = booleanFlow(VideoAudioPreferences.KEY_PREFER_ORIGINAL_AUDIO, VideoAudioPreferences.DEFAULT_PREFER_ORIGINAL_AUDIO)
val preferDescriptiveAudio: StateFlow<Boolean>
field = MutableStateFlow(
settings.getBoolean(
VideoAudioPreferences.KEY_PREFER_DESCRIPTIVE_AUDIO,
VideoAudioPreferences.DEFAULT_PREFER_DESCRIPTIVE_AUDIO
)
)
field = booleanFlow(VideoAudioPreferences.KEY_PREFER_DESCRIPTIVE_AUDIO, VideoAudioPreferences.DEFAULT_PREFER_DESCRIPTIVE_AUDIO)
fun setDefaultResolution(value: String) {
settings.putString(VideoAudioPreferences.KEY_DEFAULT_RESOLUTION, value)
defaultResolution.value = value
}
val useExternalVideoPlayer: StateFlow<Boolean>
field = booleanFlow(VideoAudioPreferences.KEY_USE_EXTERNAL_VIDEO_PLAYER, VideoAudioPreferences.DEFAULT_USE_EXTERNAL_VIDEO_PLAYER)
fun setDefaultPopupResolution(value: String) {
settings.putString(VideoAudioPreferences.KEY_DEFAULT_POPUP_RESOLUTION, value)
defaultPopupResolution.value = value
}
val useExternalAudioPlayer: StateFlow<Boolean>
field = booleanFlow(VideoAudioPreferences.KEY_USE_EXTERNAL_AUDIO_PLAYER, VideoAudioPreferences.DEFAULT_USE_EXTERNAL_AUDIO_PLAYER)
fun setMobileDataResolution(value: String) {
settings.putString(VideoAudioPreferences.KEY_LIMIT_MOBILE_DATA_USAGE, value)
mobileDataResolution.value = value
}
val showPlayWithKodi: StateFlow<Boolean>
field = booleanFlow(VideoAudioPreferences.KEY_SHOW_PLAY_WITH_KODI, VideoAudioPreferences.DEFAULT_SHOW_PLAY_WITH_KODI)
val seekbarPreviewThumbnail: StateFlow<String>
field = stringFlow(VideoAudioPreferences.KEY_SEEKBAR_PREVIEW_THUMBNAIL, VideoAudioPreferences.DEFAULT_SEEKBAR_PREVIEW_THUMBNAIL)
val preferredOpenAction: StateFlow<String>
field = stringFlow(VideoAudioPreferences.KEY_PREFERRED_OPEN_ACTION, VideoAudioPreferences.DEFAULT_PREFERRED_OPEN_ACTION)
val minimizeOnExit: StateFlow<String>
field = stringFlow(VideoAudioPreferences.KEY_MINIMIZE_ON_EXIT, VideoAudioPreferences.DEFAULT_MINIMIZE_ON_EXIT)
val startMainPlayerFullscreen: StateFlow<Boolean>
field = booleanFlow(VideoAudioPreferences.KEY_START_MAIN_PLAYER_FULLSCREEN, VideoAudioPreferences.DEFAULT_START_MAIN_PLAYER_FULLSCREEN)
val autoplay: StateFlow<String>
field = stringFlow(VideoAudioPreferences.KEY_AUTOPLAY, VideoAudioPreferences.DEFAULT_AUTOPLAY)
val autoQueue: StateFlow<Boolean>
field = booleanFlow(VideoAudioPreferences.KEY_AUTO_QUEUE, VideoAudioPreferences.DEFAULT_AUTO_QUEUE)
val resumeOnAudioFocusGain: StateFlow<Boolean>
field = booleanFlow(VideoAudioPreferences.KEY_RESUME_ON_AUDIO_FOCUS_GAIN, VideoAudioPreferences.DEFAULT_RESUME_ON_AUDIO_FOCUS_GAIN)
val leftGestureControl: StateFlow<String>
field = stringFlow(VideoAudioPreferences.KEY_LEFT_GESTURE_CONTROL, VideoAudioPreferences.DEFAULT_LEFT_GESTURE_CONTROL)
val rightGestureControl: StateFlow<String>
field = stringFlow(VideoAudioPreferences.KEY_RIGHT_GESTURE_CONTROL, VideoAudioPreferences.DEFAULT_RIGHT_GESTURE_CONTROL)
val popupRememberSizePos: StateFlow<Boolean>
field = booleanFlow(VideoAudioPreferences.KEY_POPUP_REMEMBER_SIZE_POS, VideoAudioPreferences.DEFAULT_POPUP_REMEMBER_SIZE_POS)
val useInexactSeek: StateFlow<Boolean>
field = booleanFlow(VideoAudioPreferences.KEY_USE_INEXACT_SEEK, VideoAudioPreferences.DEFAULT_USE_INEXACT_SEEK)
val seekDuration: StateFlow<String>
field = stringFlow(VideoAudioPreferences.KEY_SEEK_DURATION, VideoAudioPreferences.DEFAULT_SEEK_DURATION_MS)
val clearQueueConfirmation: StateFlow<Boolean>
field = booleanFlow(VideoAudioPreferences.KEY_CLEAR_QUEUE_CONFIRMATION, VideoAudioPreferences.DEFAULT_CLEAR_QUEUE_CONFIRMATION)
val ignoreHardwareMediaButtons: StateFlow<Boolean>
field = booleanFlow(VideoAudioPreferences.KEY_IGNORE_HARDWARE_MEDIA_BUTTONS, VideoAudioPreferences.DEFAULT_IGNORE_HARDWARE_MEDIA_BUTTONS)
fun setDefaultResolution(value: String) = defaultResolution.persist(VideoAudioPreferences.KEY_DEFAULT_RESOLUTION, value)
fun setDefaultPopupResolution(value: String) = defaultPopupResolution.persist(VideoAudioPreferences.KEY_DEFAULT_POPUP_RESOLUTION, value)
fun setMobileDataResolution(value: String) = mobileDataResolution.persist(VideoAudioPreferences.KEY_LIMIT_MOBILE_DATA_USAGE, value)
fun setShowHigherResolutions(value: Boolean) {
settings.putBoolean(VideoAudioPreferences.KEY_SHOW_HIGHER_RESOLUTIONS, value)
showHigherResolutions.value = value
showHigherResolutions.persist(VideoAudioPreferences.KEY_SHOW_HIGHER_RESOLUTIONS, value)
if (!value) {
if (defaultResolution.value in VideoAudioPreferences.HIGH_RESOLUTIONS) {
@@ -111,23 +112,67 @@ class VideoAudioSettingsViewModel(private val settings: Settings) : ViewModel()
}
}
fun setVideoFormat(value: String) {
settings.putString(VideoAudioPreferences.KEY_DEFAULT_VIDEO_FORMAT, value)
videoFormat.value = value
fun setVideoFormat(value: String) = videoFormat.persist(VideoAudioPreferences.KEY_DEFAULT_VIDEO_FORMAT, value)
fun setAudioFormat(value: String) = audioFormat.persist(VideoAudioPreferences.KEY_DEFAULT_AUDIO_FORMAT, value)
fun setPreferOriginalAudio(value: Boolean) = preferOriginalAudio.persist(VideoAudioPreferences.KEY_PREFER_ORIGINAL_AUDIO, value)
fun setPreferDescriptiveAudio(value: Boolean) = preferDescriptiveAudio.persist(VideoAudioPreferences.KEY_PREFER_DESCRIPTIVE_AUDIO, value)
fun setUseExternalVideoPlayer(value: Boolean) = useExternalVideoPlayer.persist(VideoAudioPreferences.KEY_USE_EXTERNAL_VIDEO_PLAYER, value)
fun setUseExternalAudioPlayer(value: Boolean) = useExternalAudioPlayer.persist(VideoAudioPreferences.KEY_USE_EXTERNAL_AUDIO_PLAYER, value)
fun setShowPlayWithKodi(value: Boolean) = showPlayWithKodi.persist(VideoAudioPreferences.KEY_SHOW_PLAY_WITH_KODI, value)
fun setSeekbarPreviewThumbnail(value: String) = seekbarPreviewThumbnail.persist(VideoAudioPreferences.KEY_SEEKBAR_PREVIEW_THUMBNAIL, value)
fun setPreferredOpenAction(value: String) = preferredOpenAction.persist(VideoAudioPreferences.KEY_PREFERRED_OPEN_ACTION, value)
fun setMinimizeOnExit(value: String) = minimizeOnExit.persist(VideoAudioPreferences.KEY_MINIMIZE_ON_EXIT, value)
fun setStartMainPlayerFullscreen(value: Boolean) = startMainPlayerFullscreen.persist(VideoAudioPreferences.KEY_START_MAIN_PLAYER_FULLSCREEN, value)
fun setAutoplay(value: String) = autoplay.persist(VideoAudioPreferences.KEY_AUTOPLAY, value)
fun setAutoQueue(value: Boolean) = autoQueue.persist(VideoAudioPreferences.KEY_AUTO_QUEUE, value)
fun setResumeOnAudioFocusGain(value: Boolean) = resumeOnAudioFocusGain.persist(VideoAudioPreferences.KEY_RESUME_ON_AUDIO_FOCUS_GAIN, value)
fun setLeftGestureControl(value: String) = leftGestureControl.persist(VideoAudioPreferences.KEY_LEFT_GESTURE_CONTROL, value)
fun setRightGestureControl(value: String) = rightGestureControl.persist(VideoAudioPreferences.KEY_RIGHT_GESTURE_CONTROL, value)
fun setPopupRememberSizePos(value: Boolean) = popupRememberSizePos.persist(VideoAudioPreferences.KEY_POPUP_REMEMBER_SIZE_POS, value)
/**
* Enabling inexact seek hides 5-second-step durations. If the current selection becomes
* hidden it is bumped by 5 seconds, matching the legacy screen (which also showed a toast;
* the screen shows a snackbar based on the same [VideoAudioPreferences.adjustedSeekDurationMs]).
*/
fun setUseInexactSeek(value: Boolean) {
useInexactSeek.persist(VideoAudioPreferences.KEY_USE_INEXACT_SEEK, value)
VideoAudioPreferences.adjustedSeekDurationMs(seekDuration.value, value)?.let(::setSeekDuration)
}
fun setAudioFormat(value: String) {
settings.putString(VideoAudioPreferences.KEY_DEFAULT_AUDIO_FORMAT, value)
audioFormat.value = value
fun setSeekDuration(value: String) = seekDuration.persist(VideoAudioPreferences.KEY_SEEK_DURATION, value)
fun setClearQueueConfirmation(value: Boolean) = clearQueueConfirmation.persist(VideoAudioPreferences.KEY_CLEAR_QUEUE_CONFIRMATION, value)
fun setIgnoreHardwareMediaButtons(value: Boolean) = ignoreHardwareMediaButtons.persist(VideoAudioPreferences.KEY_IGNORE_HARDWARE_MEDIA_BUTTONS, value)
private fun stringFlow(key: String, defaultValue: String) = MutableStateFlow(settings.getString(key, defaultValue))
private fun booleanFlow(key: String, defaultValue: Boolean) = MutableStateFlow(settings.getBoolean(key, defaultValue))
private fun MutableStateFlow<String>.persist(key: String, newValue: String) {
settings.putString(key, newValue)
value = newValue
}
fun setPreferOriginalAudio(value: Boolean) {
settings.putBoolean(VideoAudioPreferences.KEY_PREFER_ORIGINAL_AUDIO, value)
preferOriginalAudio.value = value
}
fun setPreferDescriptiveAudio(value: Boolean) {
settings.putBoolean(VideoAudioPreferences.KEY_PREFER_DESCRIPTIVE_AUDIO, value)
preferDescriptiveAudio.value = value
private fun MutableStateFlow<Boolean>.persist(key: String, newValue: Boolean) {
settings.putBoolean(key, newValue)
value = newValue
}
}
@@ -8,6 +8,7 @@ package net.newpipe.app.screen.settings
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.v2.runComposeUiTest
import com.russhwolf.settings.MapSettings
import com.russhwolf.settings.Settings
@@ -17,6 +18,7 @@ import net.newpipe.app.preferences.VideoAudioPreferences
import newpipe.shared.generated.resources.Res
import newpipe.shared.generated.resources.best_resolution
import newpipe.shared.generated.resources.default_resolution_title
import newpipe.shared.generated.resources.settings_category_player_title
import newpipe.shared.generated.resources.settings_category_video_audio_title
import org.jetbrains.compose.resources.getString
import org.koin.dsl.module
@@ -40,6 +42,19 @@ class VideoAudioSettingsScreenTest {
)
}
@Test
fun rendersPlayerCategoryHeader() = runComposeUiTest {
withKoin(
modules = listOf(emptySettings),
content = { VideoAudioSettingsScreenContent() },
onContent = {
onNodeWithText(getString(Res.string.settings_category_player_title))
.performScrollTo()
.assertIsDisplayed()
}
)
}
@Test
fun bestResolutionShowsLocalizedLabelAsSummary() = runComposeUiTest {
withKoin(
@@ -52,4 +67,4 @@ class VideoAudioSettingsScreenTest {
}
)
}
}
}