Merge Wholphin v1.1.0 while preserving Wolfskin features #27

Merged
justinzeus merged 72 commits from fm/wolfskin-upstream-1-1-0 into main 2026-11-05 01:49:35 +01:00
7 changed files with 106 additions and 16 deletions
Showing only changes of commit 811eaed87e - Show all commits

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