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