diff --git a/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt b/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt index 8541a774e..fa1772c1c 100644 --- a/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt +++ b/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt @@ -12,6 +12,7 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource import org.jellyfin.androidtv.R import org.jellyfin.androidtv.preference.UserPreferences import org.jellyfin.androidtv.preference.UserSettingPreferences +import org.jellyfin.androidtv.preference.constant.BufferLength import org.jellyfin.androidtv.ui.browsing.MainActivity import org.jellyfin.androidtv.ui.playback.MediaManager import org.jellyfin.androidtv.ui.playback.PlaybackLauncher @@ -70,11 +71,16 @@ fun Scope.createPlaybackManager() = playbackManager(androidContext()) { } val userPreferences = get() + val bufferLength = userPreferences[UserPreferences.bufferLength] val exoPlayerOptions = ExoPlayerOptions( preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg], enableLibass = userPreferences[UserPreferences.assDirectPlay], enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled], baseDataSourceFactory = get(), + minBufferDuration = bufferLength.minBufferDuration, + maxBufferDuration = bufferLength.maxBufferDuration, + bufferForPlaybackDuration = bufferLength.bufferForPlaybackDuration, + bufferForPlaybackAfterRebufferDuration = bufferLength.bufferForPlaybackAfterRebufferDuration, ) install(exoPlayerPlugin(get(), exoPlayerOptions)) diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt b/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt index adcee8cf8..c252b8baf 100644 --- a/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt +++ b/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt @@ -10,6 +10,7 @@ import org.jellyfin.androidtv.preference.constant.BackdropBehavior import org.jellyfin.androidtv.preference.constant.ClockBehavior import org.jellyfin.androidtv.preference.constant.HEVCLevel import org.jellyfin.androidtv.preference.constant.NextUpBehavior +import org.jellyfin.androidtv.preference.constant.BufferLength import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior import org.jellyfin.androidtv.preference.constant.StillWatchingBehavior import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior @@ -115,6 +116,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore( */ var userHEVCLevel = enumPreference("user_hevc_level", HEVCLevel.AUTO) + /** + * Playback buffer size preset. + */ + var bufferLength = enumPreference("buffer_length", BufferLength.AUTO) + /* Playback - Audio related */ /** * Preferred behavior for audio streaming. diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/constant/BufferLength.kt b/app/src/main/java/org/jellyfin/androidtv/preference/constant/BufferLength.kt new file mode 100644 index 000000000..d4bcb01e8 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/constant/BufferLength.kt @@ -0,0 +1,44 @@ +package org.jellyfin.androidtv.preference.constant + +import org.jellyfin.androidtv.R +import org.jellyfin.preference.PreferenceEnum +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +enum class BufferLength( + override val nameRes: Int, + val minBufferDuration: Duration? = null, + val maxBufferDuration: Duration? = null, + val bufferForPlaybackDuration: Duration? = null, + val bufferForPlaybackAfterRebufferDuration: Duration? = null, +) : PreferenceEnum { + /** + * Use default buffer durations. + */ + AUTO(R.string.playback_buffer_auto), + + /** + * Larger buffer, suitable for moderate or variable connections. + */ + @Suppress("MagicNumber") + LARGE( + nameRes = R.string.playback_buffer_large, + minBufferDuration = 50.seconds, + maxBufferDuration = 120.seconds, + bufferForPlaybackDuration = 2500.milliseconds, + bufferForPlaybackAfterRebufferDuration = 5.seconds, + ), + + /** + * Maximum buffer, intended for slow or satellite connections. + */ + @Suppress("MagicNumber") + EXTRA_LARGE( + nameRes = R.string.playback_buffer_extra_large, + minBufferDuration = 80.seconds, + maxBufferDuration = 240.seconds, + bufferForPlaybackDuration = 5.seconds, + bufferForPlaybackAfterRebufferDuration = 10.seconds, + ), +} diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/VideoManager.java b/app/src/main/java/org/jellyfin/androidtv/ui/playback/VideoManager.java index ced3883c9..a710a63bb 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/VideoManager.java +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/VideoManager.java @@ -32,6 +32,7 @@ import androidx.media3.common.Tracks; import androidx.media3.common.util.UnstableApi; import androidx.media3.datasource.DefaultDataSource; import androidx.media3.datasource.HttpDataSource; +import androidx.media3.exoplayer.DefaultLoadControl; import androidx.media3.exoplayer.DefaultRenderersFactory; import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.analytics.AnalyticsListener; @@ -48,6 +49,7 @@ import androidx.media3.ui.PlayerView; import org.jellyfin.androidtv.R; import org.jellyfin.androidtv.data.compat.StreamInfo; import org.jellyfin.androidtv.preference.UserPreferences; +import org.jellyfin.androidtv.preference.constant.BufferLength; import org.jellyfin.androidtv.preference.constant.ZoomMode; import org.jellyfin.sdk.api.client.ApiClient; import org.jellyfin.sdk.model.api.MediaStream; @@ -247,6 +249,21 @@ public class VideoManager { exoPlayerBuilder.setMediaSourceFactory(new DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)); } + BufferLength bufferLength = userPreferences.get(UserPreferences.Companion.getBufferLength()); + DefaultLoadControl loadControl; + if (bufferLength == BufferLength.LARGE) { + loadControl = new DefaultLoadControl.Builder() + .setBufferDurationsMs(50_000, 120_000, 2_500, 5_000) + .build(); + } else if (bufferLength == BufferLength.EXTRA_LARGE) { + loadControl = new DefaultLoadControl.Builder() + .setBufferDurationsMs(80_000, 240_000, 5_000, 10_000) + .build(); + } else { + loadControl = new DefaultLoadControl(); + } + exoPlayerBuilder.setLoadControl(loadControl); + exoPlayerBuilder.setAudioAttributes(new AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/settings/routes.kt b/app/src/main/java/org/jellyfin/androidtv/ui/settings/routes.kt index 87a01da35..55844cf52 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/settings/routes.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/settings/routes.kt @@ -34,6 +34,7 @@ import org.jellyfin.androidtv.ui.settings.screen.livetv.SettingsLiveTvGuideOptio import org.jellyfin.androidtv.ui.settings.screen.playback.SettingsPlaybackAVCLevelScreen import org.jellyfin.androidtv.ui.settings.screen.playback.SettingsPlaybackAdvancedScreen import org.jellyfin.androidtv.ui.settings.screen.playback.SettingsPlaybackAudioBehaviorScreen +import org.jellyfin.androidtv.ui.settings.screen.playback.SettingsPlaybackBufferLengthScreen import org.jellyfin.androidtv.ui.settings.screen.playback.SettingsPlaybackCodecScreen import org.jellyfin.androidtv.ui.settings.screen.playback.SettingsPlaybackHEVCLevelScreen import org.jellyfin.androidtv.ui.settings.screen.playback.SettingsPlaybackInactivityPromptScreen @@ -97,6 +98,7 @@ object Routes { const val PLAYBACK_MAX_BITRATE = "/playback/max-bitrate" const val PLAYBACK_REFRESH_RATE_SWITCHING_BEHAVIOR = "/playback/refresh-rate-switching-behavior" const val PLAYBACK_ZOOM_MODE = "/playback/zoom-mode" + const val PLAYBACK_BUFFER_LENGTH = "/playback/buffer-length" const val PLAYBACK_AUDIO_BEHAVIOR = "/playback/audio-behavior" const val PLAYBACK_CODEC = "/playback/codec" const val PLAYBACK_AVC_LEVEL = "/playback/codec/avc-level" @@ -248,6 +250,9 @@ val routes = mapOf( Routes.PLAYBACK_ZOOM_MODE to { SettingsPlaybackZoomModeScreen() }, + Routes.PLAYBACK_BUFFER_LENGTH to { + SettingsPlaybackBufferLengthScreen() + }, Routes.PLAYBACK_AUDIO_BEHAVIOR to { SettingsPlaybackAudioBehaviorScreen() }, diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/settings/screen/playback/SettingsPlaybackAdvancedScreen.kt b/app/src/main/java/org/jellyfin/androidtv/ui/settings/screen/playback/SettingsPlaybackAdvancedScreen.kt index 83bb7fb7c..48af9990b 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/settings/screen/playback/SettingsPlaybackAdvancedScreen.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/settings/screen/playback/SettingsPlaybackAdvancedScreen.kt @@ -22,6 +22,7 @@ import org.jellyfin.androidtv.R import org.jellyfin.androidtv.constant.getQualityProfiles import org.jellyfin.androidtv.preference.UserPreferences import org.jellyfin.androidtv.preference.UserSettingPreferences +import org.jellyfin.androidtv.preference.constant.BufferLength import org.jellyfin.androidtv.ui.base.Text import org.jellyfin.androidtv.ui.base.form.Checkbox import org.jellyfin.androidtv.ui.base.form.RangeControl @@ -107,6 +108,16 @@ fun SettingsPlaybackAdvancedScreen() { } } + item { + var bufferLength by rememberPreference(userPreferences, UserPreferences.bufferLength) + + ListButton( + headingContent = { Text(stringResource(R.string.playback_buffer_length)) }, + captionContent = { Text(stringResource(bufferLength.nameRes)) }, + onClick = { router.push(Routes.PLAYBACK_BUFFER_LENGTH) }, + ) + } + item { ListSection(headingContent = { Text(stringResource(R.string.pref_video)) }) } item { diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/settings/screen/playback/SettingsPlaybackBufferLengthScreen.kt b/app/src/main/java/org/jellyfin/androidtv/ui/settings/screen/playback/SettingsPlaybackBufferLengthScreen.kt new file mode 100644 index 000000000..afc419573 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/ui/settings/screen/playback/SettingsPlaybackBufferLengthScreen.kt @@ -0,0 +1,45 @@ +package org.jellyfin.androidtv.ui.settings.screen.playback + +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import org.jellyfin.androidtv.R +import org.jellyfin.androidtv.preference.UserPreferences +import org.jellyfin.androidtv.preference.constant.BufferLength +import org.jellyfin.androidtv.ui.base.Text +import org.jellyfin.androidtv.ui.base.form.RadioButton +import org.jellyfin.androidtv.ui.base.list.ListButton +import org.jellyfin.androidtv.ui.base.list.ListSection +import org.jellyfin.androidtv.ui.navigation.LocalRouter +import org.jellyfin.androidtv.ui.settings.compat.rememberPreference +import org.jellyfin.androidtv.ui.settings.composable.SettingsColumn +import org.koin.compose.koinInject + +@Composable +fun SettingsPlaybackBufferLengthScreen() { + val router = LocalRouter.current + val userPreferences = koinInject() + var bufferLength by rememberPreference(userPreferences, UserPreferences.bufferLength) + + SettingsColumn { + item { + ListSection( + overlineContent = { Text(stringResource(R.string.pref_playback_advanced).uppercase()) }, + headingContent = { Text(stringResource(R.string.playback_buffer_length)) }, + ) + } + + items(BufferLength.entries) { entry -> + ListButton( + headingContent = { Text(stringResource(entry.nameRes)) }, + trailingContent = { RadioButton(checked = bufferLength == entry) }, + onClick = { + bufferLength = entry + router.back() + }, + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d3e546832..39ba40c25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -520,6 +520,10 @@ Use FFmpeg to decode audio, even if platform codecs are available. Default zoom mode Video start delay + Playback buffer size + Auto + Large + Extra large Media segment actions Ask to skip Do nothing diff --git a/playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt b/playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt index 4b366639f..1f84095ab 100644 --- a/playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt +++ b/playback/media3/exoplayer/src/main/kotlin/ExoPlayerBackend.kt @@ -15,6 +15,7 @@ import androidx.media3.common.VideoSize import androidx.media3.common.text.CueGroup import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory @@ -109,7 +110,17 @@ class ExoPlayerBackend( else renderersFactory } + val loadControl = DefaultLoadControl.Builder() + .setBufferDurationsMs( + exoPlayerOptions.minBufferDuration?.inWholeMilliseconds?.toInt() ?: DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, + exoPlayerOptions.maxBufferDuration?.inWholeMilliseconds?.toInt() ?: DefaultLoadControl.DEFAULT_MAX_BUFFER_MS, + exoPlayerOptions.bufferForPlaybackDuration?.inWholeMilliseconds?.toInt() ?: DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + exoPlayerOptions.bufferForPlaybackAfterRebufferDuration?.inWholeMilliseconds?.toInt() ?: DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + ) + .build() + ExoPlayer.Builder(context) + .setLoadControl(loadControl) .setRenderersFactory(renderersFactory) .setTrackSelector(DefaultTrackSelector(context).apply { setParameters(buildUponParameters().apply { diff --git a/playback/media3/exoplayer/src/main/kotlin/ExoPlayerOptions.kt b/playback/media3/exoplayer/src/main/kotlin/ExoPlayerOptions.kt index 5bdeedbca..27b351c40 100644 --- a/playback/media3/exoplayer/src/main/kotlin/ExoPlayerOptions.kt +++ b/playback/media3/exoplayer/src/main/kotlin/ExoPlayerOptions.kt @@ -2,10 +2,15 @@ package org.jellyfin.playback.media3.exoplayer import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultHttpDataSource +import kotlin.time.Duration data class ExoPlayerOptions( val preferFfmpeg: Boolean = false, val enableDebugLogging: Boolean = false, val enableLibass: Boolean = false, val baseDataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory(), + val minBufferDuration: Duration? = null, + val maxBufferDuration: Duration? = null, + val bufferForPlaybackDuration: Duration? = null, + val bufferForPlaybackAfterRebufferDuration: Duration? = null, )