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:
parent
2f7804fd5e
commit
811eaed87e
7 changed files with 106 additions and 16 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue