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

@ -1,6 +1,6 @@
name: "Bug Report" name: "Bug Report"
description: Report a bug description: Report a bug
title: "<title>" title: ""
labels: [ labels: [
"bug" "bug"
] ]
@ -23,6 +23,16 @@ body:
3. See error 3. See error
validations: validations:
required: false required: false
- type: dropdown
id: experimental
attributes:
label: Experimental settings
description: Do you have experiment settings turn on?
options:
- "Yes"
- "No"
validations:
required: true
- type: input - type: input
id: app-version id: app-version
attributes: attributes:

View file

@ -1,6 +1,6 @@
name: "Playback Problem" name: "Playback Problem"
description: Report an issue with playback description: Report an issue with playback
title: "<title>" title: ""
labels: [ labels: [
"bug", "playback" "bug", "playback"
] ]
@ -12,12 +12,14 @@ body:
description: Please enter a description of the playback issue description: Please enter a description of the playback issue
validations: validations:
required: true required: true
- type: textarea - type: dropdown
id: mediainfo id: experimental
attributes: attributes:
label: "Media info" label: Experimental settings
description: Please enter details about what media you are trying to play (video or audio codecs, container, etc) description: Do you have experiment settings turn on?
placeholder: Use mediainfo or "Send media info to server" in the app to gather this options:
- "Yes"
- "No"
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
@ -32,6 +34,14 @@ body:
- External App (please specify) - External App (please specify)
validations: validations:
required: true required: true
- type: textarea
id: mediainfo
attributes:
label: "Media info"
description: Please enter details about what media you are trying to play (video or audio codecs, container, etc)
placeholder: Use mediainfo or "Send media info to server" in the app to gather this
validations:
required: true
- type: input - type: input
id: app-version id: app-version
attributes: attributes:

View file

@ -97,3 +97,9 @@ Some of the models, such as `GetItemsFilter` or `ViewOptions`, are stored as JSO
These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON. These settings are similar to Room ones and are usually stored per user. These can be accessed via `KeyValueService`. The values are `@Serializable` objects saved as JSON.
This should be used sparingly. This should be used sparingly.
#### Experimental
Some settings are considered "experimental". These may cause instabilities or may need further testing. They may be changed or removed any time.
These are stored in the `ExperimentalPreferences` object in `AppPreferences`. Accessing them should always be gated with `ExperimentalPreferences.enabled`. There are a few helper functions for this purpose.

View file

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

View file

@ -174,6 +174,14 @@ class AppPreferencesSerializer
showLyrics = true showLyrics = true
showAlbumArt = true showAlbumArt = true
}.build() }.build()
experimentalPreferences =
ExperimentalPreferences
.newBuilder()
.apply {
enabled = false
videoTunnelingEnabled = false
}.build()
}.build() }.build()
override suspend fun readFrom(input: InputStream): AppPreferences { override suspend fun readFrom(input: InputStream): AppPreferences {
@ -252,6 +260,11 @@ inline fun AppPreferences.updateMusicPreferences(block: MusicPreferences.Builder
musicPreferences = musicPreferences.toBuilder().apply(block).build() 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() { fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() 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.extractor.DefaultExtractorsFactory
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import com.github.damontecres.wholphin.mpv.MpvPlayer 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.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.MediaExtensionStatus 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.PlayerBackend
import com.github.damontecres.wholphin.preferences.get
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -61,8 +62,9 @@ class PlayerFactory
suspend fun createVideoPlayer( suspend fun createVideoPlayer(
backend: PlayerBackend, backend: PlayerBackend,
prefs: PlaybackPreferences, appPreferences: AppPreferences,
): PlayerCreation { ): PlayerCreation {
val prefs = appPreferences.playbackPreferences
withContext(WholphinDispatchers.Main) { withContext(WholphinDispatchers.Main) {
if (currentPlayer?.isReleased == false) { if (currentPlayer?.isReleased == false) {
Timber.w("Player was not released before trying to create a new one!") Timber.w("Player was not released before trying to create a new one!")
@ -105,6 +107,7 @@ class PlayerFactory
WholphinRenderersFactory(context, decodeAv1) WholphinRenderersFactory(context, decodeAv1)
.setEnableDecoderFallback(true) .setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode) .setExtensionRendererMode(rendererMode)
val mediaSourceFactory = val mediaSourceFactory =
if (useLibAss) { if (useLibAss) {
val renderType = val renderType =
@ -129,7 +132,9 @@ class PlayerFactory
extractorsFactory, extractorsFactory,
) )
} }
val trackSelector = createTrackSelector() val tunneling =
appPreferences.experimentalPreferences.get { videoTunnelingEnabled }
val trackSelector = createTrackSelector(tunneling)
ExoPlayer ExoPlayer
.Builder(context) .Builder(context)
@ -200,11 +205,13 @@ class PlayerFactory
.setConstantBitrateSeekingEnabled(true) .setConstantBitrateSeekingEnabled(true)
.setConstantBitrateSeekingAlwaysEnabled(true) .setConstantBitrateSeekingAlwaysEnabled(true)
private fun createTrackSelector() = private fun createTrackSelector(tunneling: Boolean? = null) =
DefaultTrackSelector(context).apply { DefaultTrackSelector(context).apply {
setParameters( setParameters(
buildUponParameters() buildUponParameters()
.setAudioOffloadPreferences( .apply {
tunneling?.let { setTunnelingEnabled(tunneling) }
}.setAudioOffloadPreferences(
AudioOffloadPreferences AudioOffloadPreferences
.Builder() .Builder()
.setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)

View file

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

View file

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

View file

@ -48,13 +48,16 @@ import coil3.imageLoader
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences 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.ExoPlayerPreferences
import com.github.damontecres.wholphin.preferences.ExperimentalPreference
import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.SkipSegmentPreferences import com.github.damontecres.wholphin.preferences.SkipSegmentPreferences
import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences 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.screensaverPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.Release
@ -148,6 +151,7 @@ fun PreferencesContent(
PreferenceScreenOption.MPV -> MpvPreferences PreferenceScreenOption.MPV -> MpvPreferences
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences
PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences
} }
val screenTitle = val screenTitle =
when (preferenceScreenOption) { when (preferenceScreenOption) {
@ -157,6 +161,7 @@ fun PreferencesContent(
PreferenceScreenOption.MPV -> R.string.mpv_options PreferenceScreenOption.MPV -> R.string.mpv_options
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior
PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings
} }
var visible by remember { mutableStateOf(false) } 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 -> { else -> {
val value = pref.getter.invoke(preferences) val value = pref.getter.invoke(preferences)
ComposablePreference( ComposablePreference(
@ -762,6 +807,7 @@ fun PreferencesPage(
PreferenceScreenOption.MPV, PreferenceScreenOption.MPV,
PreferenceScreenOption.SCREENSAVER, PreferenceScreenOption.SCREENSAVER,
PreferenceScreenOption.SKIP_SEGMENTS, PreferenceScreenOption.SKIP_SEGMENTS,
PreferenceScreenOption.EXPERIMENTAL,
-> { -> {
PreferencesContent( PreferencesContent(
initialPreferences, initialPreferences,

View file

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

View file

@ -212,6 +212,11 @@ message MusicPreferences {
bool show_backdrop = 4; bool show_backdrop = 4;
} }
message ExperimentalPreferences {
bool enabled = 1;
bool video_tunneling_enabled = 2;
}
message AppPreferences { message AppPreferences {
// The currently signed in server and user IDs, mostly for restoring a session // The currently signed in server and user IDs, mostly for restoring a session
string current_server_id = 1; string current_server_id = 1;
@ -229,4 +234,5 @@ message AppPreferences {
bool sign_in_automatically = 11; bool sign_in_automatically = 11;
PhotoPreferences photo_preferences = 12; PhotoPreferences photo_preferences = 12;
MusicPreferences music_preferences = 13; MusicPreferences music_preferences = 13;
ExperimentalPreferences experimental_preferences = 14;
} }

View file

@ -829,4 +829,8 @@
<string name="popularity">Popularity</string> <string name="popularity">Popularity</string>
<string name="production_status">Production status</string> <string name="production_status">Production status</string>
<string name="different_for_hdr">Use different style for HDR</string> <string name="different_for_hdr">Use different style for HDR</string>
<string name="experimental_settings">Experimental settings</string>
<string name="confirm_enable_experimental_title">Enable experimental settings?</string>
<string name="confirm_enable_experimental_body">Experimental settings may be unstable!\n\n These may removed or changed in any release.</string>
<string name="video_tunneling">Video tunneling</string>
</resources> </resources>