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.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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() =
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue