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
This commit is contained in:
Damontecres 2026-10-28 15:17:02 -04:00 • committed by GitHub
parent 2f7804fd5e
commit 811eaed87e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 106 additions and 16 deletions

View file

@ -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<Pref, T> {
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<Pref, T> {
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
}

View file

@ -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() =

View file

@ -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,
)
}
}
}

View file

@ -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 =

View file

@ -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

View file

@ -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,

View file

@ -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 <T> BottomDialog(
gravity: Int,
currentChoice: BottomDialogItem<T>? = 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 <T> BottomDialog(
),
) {
LazyColumn(
state = listState,
modifier =
Modifier
.fillMaxWidth()
@ -624,7 +637,10 @@ fun <T> BottomDialog(
}
},
interactionSource = interactionSource,
modifier = Modifier.focusRequester(focusRequesters[index]),
modifier =
Modifier
.bringIntoViewRequester(bringIntoViewRequesters[index])
.focusRequester(focusRequesters[index]),
)
}
}