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.R
import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.services.UpdateChecker 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.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup 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 MEGA_BIT = 1024 * 1024L
const val DEFAULT_BITRATE = 100 * MEGA_BIT const val DEFAULT_BITRATE = 100 * MEGA_BIT
private val bitrateValues = val bitrateValues =
listOf( listOf(
500 * 1024L, 500 * 1024L,
750 * 1024L, 750 * 1024L,
@ -378,11 +379,7 @@ sealed interface AppPreference<Pref, T> {
summarizer = { value -> summarizer = { value ->
if (value != null) { if (value != null) {
val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE
if (v < MEGA_BIT) { formatMaxBitrate(v)
"${v / 1024} kbps"
} else {
"${v / MEGA_BIT} Mbps"
}
} else { } else {
null null
} }

View file

@ -9,6 +9,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication 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 com.github.damontecres.wholphin.ui.setup.ServerVersionSupported
import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
@ -208,6 +209,13 @@ fun formatBytes(
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000) 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 @get:StringRes
val MediaSegmentType.stringRes: Int val MediaSegmentType.stringRes: Int
get() = get() =

View file

@ -42,8 +42,10 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex 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.AppColors
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent 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.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog
import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem
@ -60,6 +62,7 @@ enum class PlaybackDialogType {
PLAYBACK_SPEED, PLAYBACK_SPEED,
VIDEO_SCALE, VIDEO_SCALE,
SUBTITLE_DELAY, SUBTITLE_DELAY,
BITRATE,
} }
data class PlaybackSettings( data class PlaybackSettings(
@ -73,6 +76,7 @@ data class PlaybackSettings(
val subtitleDelay: Duration, val subtitleDelay: Duration,
val hasSubtitleDownloadPermission: Boolean, val hasSubtitleDownloadPermission: Boolean,
val playbackSpeedEnabled: 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( add(
BottomDialogItem( BottomDialogItem(
data = PlaybackDialogType.DEBUG, 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() player.seekToPrevious()
} }
} }
is PlaybackAction.ChangeBitrate -> {
viewModel.changeMaxBitrate(it.maxBitrate)
}
} }
} }
@ -752,6 +756,7 @@ fun PlaybackPageContent(
// TODO Passing through audio prevents changing playback speed // TODO Passing through audio prevents changing playback speed
// See https://github.com/damontecres/Wholphin/issues/164 // See https://github.com/damontecres/Wholphin/issues/164
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null, playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null,
maxBitrate = state.maxBitrate,
), ),
onDismissRequest = { onDismissRequest = {
playbackDialog = playbackDialog =

View file

@ -24,6 +24,7 @@ data class PlaybackState(
val nextUp: BaseItem? = null, val nextUp: BaseItem? = null,
val playlistIndex: Int = 0, val playlistIndex: Int = 0,
val playlist: Playlist = Playlist(emptyList()), val playlist: Playlist = Playlist(emptyList()),
val maxBitrate: Long = -1L,
) { ) {
val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size
val hasPrevious: Boolean get() = playlistIndex > 0 val hasPrevious: Boolean get() = playlistIndex > 0

View file

@ -292,8 +292,16 @@ class PlaybackViewModel
*/ */
private suspend fun init() { private suspend fun init() {
musicService.stop() musicService.stop()
_state.update { it.copy(nextUp = null) }
this.preferences = userPreferencesService.getCurrent() 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) { if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
addCloseable { refreshRateService.resetRefreshRate() } addCloseable { refreshRateService.resetRefreshRate() }
} }
@ -628,6 +636,7 @@ class PlaybackViewModel
positionMs: Long = 0, positionMs: Long = 0,
enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectPlay: Boolean = !this.forceTranscoding,
enableDirectStream: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding,
maxBitrate: Long = this.state.value.maxBitrate,
): Unit = ): Unit =
withContext(WholphinDispatchers.IO) { withContext(WholphinDispatchers.IO) {
val itemId = item.id val itemId = item.id
@ -638,7 +647,8 @@ class PlaybackViewModel
state.value.currentPlayback?.let { currentPlayback -> state.value.currentPlayback?.let { currentPlayback ->
if (currentPlayback.item.id == item.id && if (currentPlayback.item.id == item.id &&
currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY &&
enableDirectPlay enableDirectPlay &&
state.value.maxBitrate == maxBitrate
) { ) {
val wasSuccessful = val wasSuccessful =
changeStreamsDirectPlay( changeStreamsDirectPlay(
@ -651,18 +661,17 @@ class PlaybackViewModel
} }
Timber.i( 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, itemId,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
enableDirectPlay, enableDirectPlay,
enableDirectStream, enableDirectStream,
positionMs, positionMs,
maxBitrate,
) )
_state.update { it.copy(maxBitrate = maxBitrate) }
val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
val response by val response by
api.mediaInfoApi api.mediaInfoApi
.getPostedPlaybackInfo( .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( internal suspend fun saveTrackSelection(
trackIndex: Int, trackIndex: Int,
type: MediaStreamType, type: MediaStreamType,

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed 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.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -122,6 +123,10 @@ sealed interface PlaybackAction {
data object Previous : PlaybackAction data object Previous : PlaybackAction
data object Next : PlaybackAction data object Next : PlaybackAction
data class ChangeBitrate(
val maxBitrate: Long,
) : PlaybackAction
} }
/** /**
@ -561,12 +566,19 @@ fun <T> BottomDialog(
gravity: Int, gravity: Int,
currentChoice: BottomDialogItem<T>? = null, 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 focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } }
val bringIntoViewRequesters =
remember(choices.size) { List(choices.size) { BringIntoViewRequester() } }
if (currentChoice != null) { if (currentChoice != null) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
choices.indexOfFirstOrNull { it == currentChoice }?.let { bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView()
focusRequesters.getOrNull(it)?.tryRequestFocus() focusRequesters.getOrNull(initialIndex)?.tryRequestFocus()
}
} }
} }
// TODO enforcing a width ends up ignore the gravity // TODO enforcing a width ends up ignore the gravity
@ -591,6 +603,7 @@ fun <T> BottomDialog(
), ),
) { ) {
LazyColumn( LazyColumn(
state = listState,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -624,7 +637,10 @@ fun <T> BottomDialog(
} }
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = Modifier.focusRequester(focusRequesters[index]), modifier =
Modifier
.bringIntoViewRequester(bringIntoViewRequesters[index])
.focusRequester(focusRequesters[index]),
) )
} }
} }