From 811eaed87eab86648370146cc6d7145dec197ce9 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 28 Oct 2026 15:17:02 -0400 Subject: [PATCH] Change max bitrate during playback (#2097) ## Description Adds an option to the change the max bitrate during playback. It defaults to the global setting and can be changed in the settings (gear icon) on the playback controls. It is applied for the entire playback session. So if you start an episode and change it, will be used for each next up episode. If you back out of playback and start again, it will reset back to the global default. ### Related issues Closes #1382 ### Testing Testing on the emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/preferences/AppPreference.kt | 9 ++-- .../damontecres/wholphin/ui/Formatting.kt | 8 ++++ .../wholphin/ui/playback/PlaybackDialog.kt | 34 +++++++++++++++ .../wholphin/ui/playback/PlaybackPage.kt | 5 +++ .../wholphin/ui/playback/PlaybackState.kt | 1 + .../wholphin/ui/playback/PlaybackViewModel.kt | 41 ++++++++++++++++--- .../ui/playback/overlay/PlaybackControls.kt | 24 +++++++++-- 7 files changed, 106 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 05dd6d27..68653272 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.ui.formatMaxBitrate import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup @@ -345,7 +346,7 @@ sealed interface AppPreference { const val MEGA_BIT = 1024 * 1024L const val DEFAULT_BITRATE = 100 * MEGA_BIT - private val bitrateValues = + val bitrateValues = listOf( 500 * 1024L, 750 * 1024L, @@ -378,11 +379,7 @@ sealed interface AppPreference { summarizer = { value -> if (value != null) { val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE - if (v < MEGA_BIT) { - "${v / 1024} kbps" - } else { - "${v / MEGA_BIT} Mbps" - } + formatMaxBitrate(v) } else { null } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 4293c422..c7cbaf44 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.preferences.AppPreference.Companion.MEGA_BIT import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.BaseItemDto @@ -208,6 +209,13 @@ fun formatBytes( fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000) +fun formatMaxBitrate(bitrate: Long) = + if (bitrate < MEGA_BIT) { + "${bitrate / 1024} kbps" + } else { + "${bitrate / MEGA_BIT} Mbps" + } + @get:StringRes val MediaSegmentType.stringRes: Int get() = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 491498cb..3165246f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -42,8 +42,10 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.formatMaxBitrate import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem @@ -60,6 +62,7 @@ enum class PlaybackDialogType { PLAYBACK_SPEED, VIDEO_SCALE, SUBTITLE_DELAY, + BITRATE, } data class PlaybackSettings( @@ -73,6 +76,7 @@ data class PlaybackSettings( val subtitleDelay: Duration, val hasSubtitleDownloadPermission: Boolean, val playbackSpeedEnabled: Boolean, + val maxBitrate: Long, ) /** @@ -162,6 +166,13 @@ fun PlaybackDialog( ), ) } + add( + BottomDialogItem( + data = PlaybackDialogType.BITRATE, + headline = stringResource(R.string.max_bitrate), + supporting = remember(settings.maxBitrate) { formatMaxBitrate(settings.maxBitrate) }, + ), + ) add( BottomDialogItem( data = PlaybackDialogType.DEBUG, @@ -271,6 +282,29 @@ fun PlaybackDialog( } } } + + PlaybackDialogType.BITRATE -> { + val choices = + AppPreference.bitrateValues.map { bitrate -> + BottomDialogItem( + data = bitrate, + headline = formatMaxBitrate(bitrate), + supporting = null, + ) + } + BottomDialog( + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.maxBitrate }, + onDismissRequest = { + onControllerInteraction.invoke() + onDismissRequest.invoke() + }, + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.ChangeBitrate(choice.data)) + }, + gravity = leftGravity, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 483083f2..86d64651 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -322,6 +322,10 @@ fun PlaybackPageContent( player.seekToPrevious() } } + + is PlaybackAction.ChangeBitrate -> { + viewModel.changeMaxBitrate(it.maxBitrate) + } } } @@ -752,6 +756,7 @@ fun PlaybackPageContent( // TODO Passing through audio prevents changing playback speed // See https://github.com/damontecres/Wholphin/issues/164 playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null, + maxBitrate = state.maxBitrate, ), onDismissRequest = { playbackDialog = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt index b42b7151..cca3a0d0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt @@ -24,6 +24,7 @@ data class PlaybackState( val nextUp: BaseItem? = null, val playlistIndex: Int = 0, val playlist: Playlist = Playlist(emptyList()), + val maxBitrate: Long = -1L, ) { val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size val hasPrevious: Boolean get() = playlistIndex > 0 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index f89f3af1..e51ccfbb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -292,8 +292,16 @@ class PlaybackViewModel */ private suspend fun init() { musicService.stop() - _state.update { it.copy(nextUp = null) } this.preferences = userPreferencesService.getCurrent() + val maxBitrate = + preferences.appPreferences.playbackPreferences.maxBitrate + .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE + _state.update { + it.copy( + nextUp = null, + maxBitrate = maxBitrate, + ) + } if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { addCloseable { refreshRateService.resetRefreshRate() } } @@ -628,6 +636,7 @@ class PlaybackViewModel positionMs: Long = 0, enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding, + maxBitrate: Long = this.state.value.maxBitrate, ): Unit = withContext(WholphinDispatchers.IO) { val itemId = item.id @@ -638,7 +647,8 @@ class PlaybackViewModel state.value.currentPlayback?.let { currentPlayback -> if (currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && - enableDirectPlay + enableDirectPlay && + state.value.maxBitrate == maxBitrate ) { val wasSuccessful = changeStreamsDirectPlay( @@ -651,18 +661,17 @@ class PlaybackViewModel } Timber.i( - "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s", + "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s, maxBitrate=%s", itemId, audioIndex, subtitleIndex, enableDirectPlay, enableDirectStream, positionMs, + maxBitrate, ) + _state.update { it.copy(maxBitrate = maxBitrate) } - val maxBitrate = - preferences.appPreferences.playbackPreferences.maxBitrate - .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE val response by api.mediaInfoApi .getPostedPlaybackInfo( @@ -1021,6 +1030,26 @@ class PlaybackViewModel } } + fun changeMaxBitrate(newMaxBitrate: Long) { + viewModelScope.launchIO { + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null) { + Timber.d("Changing bitrate to %s", newMaxBitrate) + changeStreams( + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = currentPlayback.audioIndex, + subtitleIndex = currentPlayback.subtitleIndex, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, + maxBitrate = newMaxBitrate, + ) + } else { + Timber.w("Trying to change bitrate, but currentPlayback is null") + } + } + } + internal suspend fun saveTrackSelection( trackIndex: Int, type: MediaStreamType, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index ba77d8b7..45fcdd4c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape @@ -122,6 +123,10 @@ sealed interface PlaybackAction { data object Previous : PlaybackAction data object Next : PlaybackAction + + data class ChangeBitrate( + val maxBitrate: Long, + ) : PlaybackAction } /** @@ -561,12 +566,19 @@ fun BottomDialog( gravity: Int, currentChoice: BottomDialogItem? = null, ) { + val initialIndex = + remember(choices, currentChoice) { choices.indexOfFirstOrNull { it == currentChoice } } ?: 0 + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = initialIndex, + ) val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + val bringIntoViewRequesters = + remember(choices.size) { List(choices.size) { BringIntoViewRequester() } } if (currentChoice != null) { LaunchedEffect(Unit) { - choices.indexOfFirstOrNull { it == currentChoice }?.let { - focusRequesters.getOrNull(it)?.tryRequestFocus() - } + bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView() + focusRequesters.getOrNull(initialIndex)?.tryRequestFocus() } } // TODO enforcing a width ends up ignore the gravity @@ -591,6 +603,7 @@ fun BottomDialog( ), ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() @@ -624,7 +637,10 @@ fun BottomDialog( } }, interactionSource = interactionSource, - modifier = Modifier.focusRequester(focusRequesters[index]), + modifier = + Modifier + .bringIntoViewRequester(bringIntoViewRequesters[index]) + .focusRequester(focusRequesters[index]), ) } }