Add experimental settings & video tunneling (#1624)

## Description
This PR introduces the concept of "experimental" settings or features.
These may cause instabilities or may need further testing. They may be
changed or removed any time. Also, the developers won't provide lots of
support for them.

The goal is expose some specialized settings that might only apply to a
small subset of users or test out tweaks to playback.

The first experimental feature is a toggle to enable [video
tunneling](https://developer.android.com/media/media3/exoplayer/track-selection#tunneling).

### Related issues
Related to #1283

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-06-27 14:29:37 -04:00 • committed by GitHub
parent 21e720c420
commit 083d4e4fa8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 183 additions and 14 deletions

View file

@ -1266,8 +1266,16 @@ val advancedPreferences =
AppPreference.DebugLogging,
AppPreference.ImageDiskCacheSize,
AppPreference.ClearImageCache,
ExperimentalPreference.Enable,
AppPreference.OssLicenseInfo,
),
conditionalPreferences =
listOf(
ConditionalPreferences(
condition = { it.experimentalPreferences.enabled },
preferences = listOf(ExperimentalPreference.ExperimentalSettings),
),
),
),
)
}

View file

@ -174,6 +174,14 @@ class AppPreferencesSerializer
showLyrics = true
showAlbumArt = true
}.build()
experimentalPreferences =
ExperimentalPreferences
.newBuilder()
.apply {
enabled = false
videoTunnelingEnabled = false
}.build()
}.build()
override suspend fun readFrom(input: InputStream): AppPreferences {
@ -252,6 +260,11 @@ inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder
musicPreferences = musicPreferences.toBuilder().apply(block).build()
}
inline fun AppPreferences.updateExperimentalPreferences(block: ExperimentalPreferences.Builder.() -> Unit): AppPreferences =
update {
experimentalPreferences = experimentalPreferences.toBuilder().apply(block).build()
}
fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()

View file

@ -0,0 +1,58 @@
package com.github.damontecres.wholphin.preferences
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
object ExperimentalPreference {
val Enable =
AppSwitchPreference<AppPreferences>(
title = R.string.experimental_settings,
defaultValue = false,
getter = { it.experimentalPreferences.enabled },
setter = { prefs, value ->
prefs.updateExperimentalPreferences { enabled = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val ExperimentalSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.experimental_settings,
destination = Destination.Settings(PreferenceScreenOption.EXPERIMENTAL),
)
val VideoTunneling =
AppSwitchPreference<AppPreferences>(
title = R.string.video_tunneling,
defaultValue = false,
getter = { it.experimentalPreferences.videoTunnelingEnabled },
setter = { prefs, value ->
prefs.updateExperimentalPreferences { videoTunnelingEnabled = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
}
val experimentalPreferences =
buildList {
add(
PreferenceGroup(
title = R.string.experimental_settings,
preferences =
listOf(
ExperimentalPreference.VideoTunneling,
),
),
)
}
/**
* Get a value from [ExperimentalPreference] or null if not enabled
*/
fun <T> ExperimentalPreferences.get(block: ExperimentalPreferences.() -> T): T? = if (enabled) block.invoke(this) else null
fun ExperimentalPreferences.enabled(block: ExperimentalPreferences.() -> Boolean): Boolean = enabled && block.invoke(this)

View file

@ -26,10 +26,11 @@ import androidx.media3.exoplayer.video.VideoRendererEventListener
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.session.MediaSession
import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.get
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext
@ -61,8 +62,9 @@ class PlayerFactory
suspend fun createVideoPlayer(
backend: PlayerBackend,
prefs: PlaybackPreferences,
appPreferences: AppPreferences,
): PlayerCreation {
val prefs = appPreferences.playbackPreferences
withContext(WholphinDispatchers.Main) {
if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!")
@ -105,6 +107,7 @@ class PlayerFactory
WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode)
val mediaSourceFactory =
if (useLibAss) {
val renderType =
@ -129,7 +132,9 @@ class PlayerFactory
extractorsFactory,
)
}
val trackSelector = createTrackSelector()
val tunneling =
appPreferences.experimentalPreferences.get { videoTunnelingEnabled }
val trackSelector = createTrackSelector(tunneling)
ExoPlayer
.Builder(context)
@ -200,11 +205,13 @@ class PlayerFactory
.setConstantBitrateSeekingEnabled(true)
.setConstantBitrateSeekingAlwaysEnabled(true)
private fun createTrackSelector() =
private fun createTrackSelector(tunneling: Boolean? = null) =
DefaultTrackSelector(context).apply {
setParameters(
buildUponParameters()
.setAudioOffloadPreferences(
.apply {
tunneling?.let { setTunnelingEnabled(tunneling) }
}.setAudioOffloadPreferences(
AudioOffloadPreferences
.Builder()
.setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)

View file

@ -253,7 +253,7 @@ class PlaybackViewModel
val playerCreation =
playerFactory.createVideoPlayer(
playerBackend,
preferences.appPreferences.playbackPreferences,
preferences.appPreferences,
)
this.player = playerCreation.player
currentPlayer.update {

View file

@ -37,6 +37,7 @@ enum class PreferenceScreenOption {
MPV,
SCREENSAVER,
SKIP_SEGMENTS,
EXPERIMENTAL,
;
companion object {

View file

@ -48,13 +48,16 @@ import coil3.imageLoader
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
import com.github.damontecres.wholphin.preferences.MpvPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.SkipSegmentPreferences
import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.experimentalPreferences
import com.github.damontecres.wholphin.preferences.screensaverPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.Release
@ -148,6 +151,7 @@ fun PreferencesContent(
PreferenceScreenOption.MPV -> MpvPreferences
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences
}
val screenTitle =
when (preferenceScreenOption) {
@ -157,6 +161,7 @@ fun PreferencesContent(
PreferenceScreenOption.MPV -> R.string.mpv_options
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings
}
var visible by remember { mutableStateOf(false) }
@ -569,6 +574,46 @@ fun PreferencesContent(
)
}
ExperimentalPreference.Enable -> {
var showConfirm by remember { mutableStateOf(false) }
pref as AppSwitchPreference<AppPreferences>
val value = pref.getter.invoke(preferences)
SwitchPreference(
title = stringResource(pref.title),
value = value,
onClick = {
if (value) {
scope.launch(ExceptionHandler()) {
preferences =
viewModel.preferenceDataStore.updateData { prefs ->
pref.setter.invoke(prefs, false)
}
}
} else {
showConfirm = true
}
},
summaryOn = pref.summaryOn?.let { stringResource(pref.summaryOn) },
summaryOff = pref.summaryOff?.let { stringResource(pref.summaryOff) },
)
if (showConfirm) {
ConfirmDialog(
title = stringResource(R.string.confirm_enable_experimental_title),
body = stringResource(R.string.confirm_enable_experimental_body),
onCancel = { showConfirm = false },
onConfirm = {
showConfirm = false
scope.launch(ExceptionHandler()) {
preferences =
viewModel.preferenceDataStore.updateData { prefs ->
pref.setter.invoke(prefs, true)
}
}
},
)
}
}
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(
@ -762,6 +807,7 @@ fun PreferencesPage(
PreferenceScreenOption.MPV,
PreferenceScreenOption.SCREENSAVER,
PreferenceScreenOption.SKIP_SEGMENTS,
PreferenceScreenOption.EXPERIMENTAL,
-> {
PreferencesContent(
initialPreferences,

View file

@ -111,7 +111,7 @@ class SlideshowViewModel
val playerCreation =
playerFactory.createVideoPlayer(
backend = PlayerBackend.EXO_PLAYER,
appPreferences.playbackPreferences,
appPreferences,
)
player = playerCreation.player
player.addListener(this@SlideshowViewModel)