Merge branch 'main' into develop/jellyseerr

This commit is contained in:
Ray 2026-01-01 01:37:14 -05:00 committed by GitHub
commit ca0fe2ef55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 623 additions and 229 deletions

View file

@ -32,12 +32,13 @@
android:supportsRtl="true"
android:theme="@style/Theme.Wholphin"
android:name=".WholphinApplication"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout">
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
@ -122,12 +123,12 @@ class MainActivity : AppCompatActivity() {
if (savedInstanceState == null) {
appUpgradeHandler.copySubfont(false)
}
refreshRateService.refreshRateMode.observe(this) { mode ->
refreshRateService.refreshRateMode.observe(this) { modeId ->
// Listen for refresh rate changes
val attrs = window.attributes
if (attrs.preferredDisplayModeId != mode.modeId) {
Timber.d("Switch preferredRefreshRate to %s", mode.refreshRate)
window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate }
if (attrs.preferredDisplayModeId != modeId) {
Timber.d("Switch preferredDisplayModeId to %s", modeId)
window.attributes = attrs.apply { preferredDisplayModeId = modeId }
}
}
viewModel.appStart()
@ -326,6 +327,11 @@ class MainActivity : AppCompatActivity() {
Timber.d("onDestroy")
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
Timber.d("onConfigurationChanged")
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Timber.v("onNewIntent")

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.formatDateTime
@ -17,6 +18,7 @@ import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
@Serializable
@Stable
data class BaseItem(
val data: BaseItemDto,
val useSeriesForPrimary: Boolean,

View file

@ -696,7 +696,25 @@ sealed interface AppPreference<Pref, T> {
defaultValue = false,
getter = { it.playbackPreferences.refreshRateSwitching },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { refreshRateSwitching = value }
prefs.updatePlaybackPreferences {
if (!value) resolutionSwitching = false
refreshRateSwitching = value
}
},
summaryOn = R.string.automatic,
summaryOff = R.string.disabled,
)
val ResolutionSwitching =
AppSwitchPreference<AppPreferences>(
title = R.string.resolution_switching,
defaultValue = false,
getter = { it.playbackPreferences.resolutionSwitching },
setter = { prefs, value ->
prefs.updatePlaybackPreferences {
if (value) refreshRateSwitching = true
resolutionSwitching = value
}
},
summaryOn = R.string.automatic,
summaryOff = R.string.disabled,
@ -942,6 +960,7 @@ val advancedPreferences =
AppPreference.GlobalContentScale,
AppPreference.MaxBitrate,
AppPreference.RefreshRateSwitching,
AppPreference.ResolutionSwitching,
AppPreference.PlaybackDebugInfo,
),
),

View file

@ -52,6 +52,7 @@ class AppPreferencesSerializer
playerBackend = AppPreference.PlayerBackendPref.defaultValue
refreshRateSwitching =
AppPreference.RefreshRateSwitching.defaultValue
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
overrides =
PlaybackOverrides

View file

@ -28,6 +28,7 @@ class ImageUrlService
itemType: BaseItemKind,
seriesId: UUID?,
useSeriesForPrimary: Boolean,
imageTags: Map<ImageType, String?>,
imageType: ImageType,
fillWidth: Int? = null,
fillHeight: Int? = null,
@ -66,6 +67,13 @@ class ImageUrlService
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl(
itemId = seriesId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
getItemImageUrl(
itemId = itemId,
@ -98,6 +106,7 @@ class ImageUrlService
itemType = item.type,
seriesId = item.data.seriesId,
useSeriesForPrimary = item.useSeriesForPrimary,
imageTags = item.data.imageTags.orEmpty(),
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,

View file

@ -32,41 +32,61 @@ class RefreshRateService
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
private val originalMode = display.mode
val displayModes get() = display.supportedModes
val supportedDisplayModes get() = display.supportedModes.orEmpty()
private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode)
val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode
private val displayModes: List<DisplayMode> by lazy {
display.supportedModes
.orEmpty()
.map { DisplayMode(it) }
.sortedWith(
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
.thenBy { it.refreshRateRounded },
)
}
private val _refreshRateMode = EqualityMutableLiveData<Int>(originalMode.modeId)
val refreshRateMode: LiveData<Int> = _refreshRateMode
/**
* Find the best display mode for the given stream and signal to change to it
*/
suspend fun changeRefreshRate(stream: MediaStream) {
suspend fun changeRefreshRate(
stream: MediaStream,
switchRefreshRate: Boolean,
switchResolution: Boolean,
) {
if (!switchRefreshRate && !switchResolution) {
Timber.v("Not switching either refresh rate nor resolution")
return
}
val currentDisplayMode = display.mode
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
val width = stream.width
val height = stream.height
val frameRate = stream.realFrameRate?.times(100)?.roundToInt()
val frameRate =
if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate
if (width == null || height == null || frameRate == null) {
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
return
}
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
val targetMode =
display.supportedModes
.filterNot { it.physicalHeight < height || it.physicalWidth < width }
.filter {
(it.refreshRate * 100).roundToInt().let { modeRate ->
frameRate % modeRate == 0 || // Exact multiple
modeRate == (frameRate * 2.5).roundToInt() // eg 24 & 60fps
}
}.maxByOrNull { it.physicalWidth * it.physicalHeight }
Timber.i("Found display mode: %s, current=${display.mode}", targetMode)
if (targetMode != null && targetMode != display.mode) {
findDisplayMode(
displayModes = displayModes,
streamWidth = width,
streamHeight = height,
targetFrameRate = frameRate,
refreshRateSwitch = switchRefreshRate,
resolutionSwitch = switchResolution,
)
Timber.i("Found display mode: %s, current=%s", targetMode, currentDisplayMode)
if (targetMode != null && targetMode.modeId != currentDisplayMode.modeId) {
val listener = Listener(display.displayId)
displayManager.registerDisplayListener(
listener,
Handler(Looper.myLooper() ?: Looper.getMainLooper()),
)
_refreshRateMode.setValueOnMain(targetMode)
_refreshRateMode.setValueOnMain(targetMode.modeId)
try {
if (!listener.latch.await(5, TimeUnit.SECONDS)) {
Timber.w("Timed out waiting for display change")
@ -98,7 +118,7 @@ class RefreshRateService
* Reset the display mode to the original
*/
fun resetRefreshRate() {
_refreshRateMode.value = originalMode
_refreshRateMode.value = originalMode.modeId
}
private class Listener(
@ -119,4 +139,69 @@ class RefreshRateService
override fun onDisplayRemoved(displayId: Int) {
}
}
companion object {
/**
* Find the best display mode for the given stream & preferences
*
* @param displayModes candidates that are sorted by resolution and frame rate descending
*/
fun findDisplayMode(
displayModes: List<DisplayMode>,
streamWidth: Int,
streamHeight: Int,
targetFrameRate: Float,
refreshRateSwitch: Boolean,
resolutionSwitch: Boolean,
): DisplayMode? {
val streamRate = targetFrameRate.times(1000).roundToInt()
// Timber.v("display modes: %s", displayModes.joinToString("\n"))
val candidates =
if (refreshRateSwitch) {
displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
.filter {
it.refreshRateRounded % streamRate == 0 || // Exact multiple
it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
}
} else {
displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
}
// Timber.v("display modes candidates: %s", candidates.joinToString("\n"))
return if (!resolutionSwitch) {
candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
} else {
candidates.firstOrNull {
it.physicalWidth == streamWidth &&
it.physicalHeight == streamHeight &&
it.refreshRateRounded == streamRate
}
?: candidates.firstOrNull {
it.physicalWidth == streamWidth &&
it.physicalHeight >= streamHeight &&
it.refreshRateRounded == streamRate
}
?: candidates
.filter { it.refreshRateRounded == streamRate }
.maxByOrNull { it.physicalWidth * it.physicalHeight }
}
}
}
}
data class DisplayMode(
val modeId: Int,
val physicalWidth: Int,
val physicalHeight: Int,
val refreshRate: Float,
) {
val refreshRateRounded: Int = (refreshRate * 1000).roundToInt()
constructor(mode: Display.Mode) : this(
mode.modeId,
mode.physicalWidth,
mode.physicalHeight,
mode.refreshRate,
)
}

View file

@ -1,29 +1,40 @@
package com.github.damontecres.wholphin.services.tvprovider
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.hilt.work.HiltWorker
import androidx.tvprovider.media.tv.Channel
import androidx.tvprovider.media.tv.PreviewProgram
import androidx.tvprovider.media.tv.TvContractCompat
import androidx.tvprovider.media.tv.TvContractCompat.Channels
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
import androidx.tvprovider.media.tv.WatchNextProgram
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.ui.SlimItemFields
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
@ -77,6 +88,21 @@ class TvProviderWorker
val currentItems = getCurrentTvChannelNextUp()
val currentItemIds = currentItems.map { it.internalProviderId }
// TODO Remove after v0.3.10 release
// This cleans up duplicates added to the watch next due a bug in https://github.com/damontecres/Wholphin/pull/372
currentItems.groupBy { it.internalProviderId }.forEach { (id, items) ->
if (items.size > 1) {
Timber.v("Duplicate ID %s", id)
items
.subList(1, items.size)
.map { TvContractCompat.buildWatchNextProgramUri(it.id) }
.forEach {
context.contentResolver.delete(it, null, null)
}
}
}
// End temporary clean up
val toRemove =
currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds }
@ -84,7 +110,7 @@ class TvProviderWorker
val userRemovedIds = userRemoved.map { it.internalProviderId }
Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId })
val toAdd =
potentialItemsToAdd.filterNot { it.id.toString() in currentItemIds && it.id.toString() in userRemovedIds }
potentialItemsToAdd.filter { it.id.toString() !in currentItemIds && it.id.toString() !in userRemovedIds }
Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id })
// Remove existing items if they are no longer in the next up from server
@ -104,6 +130,8 @@ class TvProviderWorker
)
Timber.v("Added %s", addedCount)
addOtherChannels()
Timber.d("Completed successfully")
} catch (_: ApiClientException) {
return Result.retry()
@ -226,6 +254,142 @@ class TvProviderWorker
)
}.build()
private suspend fun addOtherChannels() {
val preferences = preferences.data.firstOrNull()
val channelsPrefs = context.getSharedPreferences("channels", Context.MODE_PRIVATE)
val latest =
api.userLibraryApi
.getLatestMedia(
GetLatestMediaRequest(
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = null,
groupItems = true,
limit =
preferences?.homePagePreferences?.maxItemsPerRow
?: AppPreference.HomePageItems.defaultValue.toInt(),
isPlayed = null, // Server will handle user's preference
),
).content
.map { BaseItem(it, true) }
var channelId = channelsPrefs.getString("latest", null)?.toUri()
if (channelId == null) {
Timber.d("channelId for latest is null")
val channel =
Channel
.Builder()
.apply {
setDisplayName(context.getString(R.string.recently_added))
setType(Channels.TYPE_PREVIEW)
setAppLinkIntent(Intent(context, MainActivity::class.java))
}.build()
channelId =
context.contentResolver.insert(
Channels.CONTENT_URI,
channel.toContentValues(),
)
if (channelId != null) {
channelsPrefs.edit(true) {
putString("latest", channelId.toString())
}
TvContractCompat.requestChannelBrowsable(
context,
ContentUris.parseId(channelId),
)
} else {
Timber.w("channelId was null")
throw IllegalStateException("channelId was null")
}
}
val programs = latest.map { convert(channelId, it).toContentValues() }.toTypedArray()
// Delete & replace
context.contentResolver.delete(TvContractCompat.PreviewPrograms.CONTENT_URI, null, null)
val count =
context.contentResolver.bulkInsert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
programs,
)
Timber.v("Inserted $count records")
}
private fun convert(
channelId: Uri,
item: BaseItem,
): PreviewProgram =
PreviewProgram
.Builder()
.apply {
setChannelId(ContentUris.parseId(channelId))
val dto = item.data
setInternalProviderId(item.id.toString())
val type =
when (item.type) {
BaseItemKind.SERIES -> WatchNextPrograms.TYPE_TV_SERIES
BaseItemKind.SEASON -> WatchNextPrograms.TYPE_TV_SEASON
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
else -> WatchNextPrograms.TYPE_CLIP
}
setType(type)
val resumePosition = dto.userData?.playbackPositionTicks?.ticks
if (resumePosition != null && resumePosition >= 2.minutes) {
// https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content
setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt())
}
dto.runTimeTicks
?.ticks
?.inWholeMilliseconds
?.toInt()
?.let(::setDurationMillis)
setTitle(item.title)
setDescription(dto.overview)
if (item.type == BaseItemKind.EPISODE) {
setEpisodeTitle(item.name)
dto.indexNumber?.let(::setEpisodeNumber)
dto.parentIndexNumber?.let(::setSeasonNumber)
}
setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9)
setPosterArtUri(imageUrlService.getItemImageUrl(item, ImageType.THUMB)!!.toUri())
setIntent(
Intent(context, MainActivity::class.java)
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
.apply {
if (item.type == BaseItemKind.EPISODE) {
putExtra(
MainActivity.INTENT_SERIES_ID,
dto.seriesId?.toString(),
)
putExtra(
MainActivity.INTENT_SEASON_ID,
dto.seasonId?.toString(),
)
dto.parentIndexNumber?.let {
putExtra(
MainActivity.INTENT_SEASON_NUMBER,
it,
)
}
dto.indexNumber?.let {
putExtra(
MainActivity.INTENT_EPISODE_NUMBER,
it,
)
}
}
},
)
}.build()
companion object {
const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker"
const val PARAM_USER_ID = "userId"

View file

@ -8,22 +8,15 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.util.FocusPair
@Composable
fun <T> ItemRow(
@ -39,13 +32,10 @@ fun <T> ItemRow(
onLongClick: () -> Unit,
) -> Unit,
modifier: Modifier = Modifier,
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
horizontalPadding: Dp = 16.dp,
) {
val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() }
var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
@ -62,23 +52,14 @@ fun <T> ItemRow(
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusPair?.focusRequester ?: firstFocus),
.focusRestorer(firstFocus),
) {
itemsIndexed(items) { index, item ->
val cardModifier =
if (index == 0 && focusPair == null) {
if (index == 0) {
Modifier.focusRequester(firstFocus)
} else {
if (focusPair != null && focusPair.column == index) {
Modifier.focusRequester(focusPair.focusRequester)
} else {
Modifier
}
}.onFocusChanged {
if (it.isFocused) {
focusedIndex = index
}
cardOnFocus?.invoke(it.isFocused, index)
Modifier
}
cardContent.invoke(
index,
@ -91,38 +72,3 @@ fun <T> ItemRow(
}
}
}
@Composable
fun BannerItemRow(
title: String,
items: List<BaseItem?>,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
modifier: Modifier = Modifier,
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
aspectRatioOverride: Float? = null,
) = ItemRow(
title = title,
items = items,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
modifier = modifier,
cardContent = { index, item, modifier, onClick, onLongClick ->
BannerCard(
name = title,
item = item,
aspectRatio =
aspectRatioOverride ?: item?.aspectRatio ?: AspectRatios.WIDE,
cornerText = item?.data?.indexNumber?.let { "E$it" },
played = item?.data?.userData?.played ?: false,
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
interactionSource = null,
)
},
focusPair = focusPair,
cardOnFocus = cardOnFocus,
)

View file

@ -518,6 +518,7 @@ fun CollectionFolderGrid(
playEnabled: Boolean,
defaultViewOptions: ViewOptions,
modifier: Modifier = Modifier,
viewModelKey: String? = itemId.toServerString(),
initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
@ -531,6 +532,7 @@ fun CollectionFolderGrid(
onClickItem,
sortOptions,
playEnabled,
viewModelKey = viewModelKey,
defaultViewOptions = defaultViewOptions,
modifier = modifier,
initialSortAndDirection = initialSortAndDirection,
@ -551,6 +553,7 @@ fun CollectionFolderGrid(
playEnabled: Boolean,
defaultViewOptions: ViewOptions,
modifier: Modifier = Modifier,
viewModelKey: String? = itemId,
initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
@ -559,7 +562,7 @@ fun CollectionFolderGrid(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
viewModel: CollectionFolderViewModel =
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
key = itemId,
key = viewModelKey,
) {
it.create(
itemId = itemId,

View file

@ -136,11 +136,12 @@ class GenreViewModel
// excludeItemIds.add(item.id)
genreToUrl[genre.id] =
imageUrlService.getItemImageUrl(
item.id,
item.type,
null,
false,
ImageType.BACKDROP,
itemId = item.id,
itemType = item.type,
seriesId = null,
useSeriesForPrimary = false,
imageType = ImageType.BACKDROP,
imageTags = emptyMap(),
fillWidth = cardWidthPx,
)
}

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -21,7 +22,7 @@ fun MovieQuickDetails(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val now = LocalClock.current.now
val now by LocalClock.current.now
val details =
remember(dto, now) {
buildList {

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -59,7 +60,7 @@ fun EpisodeQuickDetails(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val now = LocalClock.current.now
val now by LocalClock.current.now
val details =
remember(dto, now) {
buildList {

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@ -13,8 +14,9 @@ import com.github.damontecres.wholphin.ui.util.LocalClock
@Composable
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
val timeString by LocalClock.current.timeString
Text(
text = LocalClock.current.timeString,
text = timeString,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,

View file

@ -115,6 +115,7 @@ fun CollectionFolderMovie(
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
itemId = destination.itemId,
viewModelKey = "${destination.itemId}_library",
initialFilter =
CollectionFolderFilter(
filter =
@ -146,6 +147,7 @@ fun CollectionFolderMovie(
preferencesViewModel.navigationManager.navigateTo(item.destination())
},
itemId = destination.itemId,
viewModelKey = "${destination.itemId}_collection",
initialFilter =
CollectionFolderFilter(
filter =

View file

@ -254,7 +254,7 @@ fun DebugPage(
"Manufacturer: ${Build.MANUFACTURER}",
"Model: ${Build.MODEL}",
"Display Modes:",
*viewModel.refreshRateService.displayModes,
*viewModel.refreshRateService.supportedDisplayModes,
).forEach {
Text(
text = it.toString(),

View file

@ -410,7 +410,7 @@ fun PlaylistItem(
},
trailingContent = {
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration ->
val now = LocalClock.current.now
val now by LocalClock.current.now
val endTimeStr =
remember(item, now) {
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)

View file

@ -19,6 +19,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -56,7 +57,6 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import eu.wewox.programguide.ProgramGuide
import eu.wewox.programguide.ProgramGuideDimensions
@ -289,22 +289,8 @@ fun TvGuideGridContent(
var focusedItem by rememberPosition(RowColumn(0, 0))
val focusedChannelIndex = focusedItem.row
val focusedProgramIndex =
remember(programs.range, focusedItem) {
focusedItem.let { focus ->
(programs.range.first..<focus.row).sumOf {
val channelId = channels[it].id
channelProgramCount[channelId] ?: 0
} + focus.column
}
}
var focusedProgramIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(focusedProgramIndex) {
// Timber.v("Focusing on $focusedItem, programIndex=$focusedProgramIndex")
scope.launch(ExceptionHandler()) {
state.animateToProgram(focusedProgramIndex, Alignment.Center)
}
}
var gridHasFocus by rememberSaveable { mutableStateOf(false) }
var channelColumnFocused by rememberSaveable { mutableStateOf(false) }
Box(modifier = modifier) {
@ -485,7 +471,7 @@ fun TvGuideGridContent(
if (newFocusedItem != null) {
val channel = channels[newFocusedItem.row]
val programs = programs.programsByChannel[channel.id].orEmpty()
val channelPrograms = programs.programsByChannel[channel.id].orEmpty()
// Ensure it isn't going out of range
val toFocus =
newFocusedItem
@ -494,10 +480,24 @@ fun TvGuideGridContent(
column =
newFocusedItem.column.coerceIn(
0,
(programs.size - 1).coerceAtLeast(0),
(channelPrograms.size - 1).coerceAtLeast(0),
),
)
focusedItem = toFocus
focusedProgramIndex =
toFocus.let { focus ->
(programs.range.first..<focus.row).sumOf {
val channelId = channels[it].id
channelProgramCount[channelId] ?: 0
} + focus.column
}
scope.launch {
try {
state.animateToProgram(focusedProgramIndex, Alignment.Center)
} catch (ex: Exception) {
Timber.e(ex, "Couldn't scroll to $focusedProgramIndex")
}
}
onFocus(toFocus)
return@onPreviewKeyEvent true
}

View file

@ -66,7 +66,6 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
@ -226,14 +225,15 @@ fun HomePageContent(
var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(firstRow, 0))
}
var focusedItem =
position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
val focusedItem =
remember(position) {
position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
}
}
val listState = rememberLazyListState()
val focusRequester = remember { FocusRequester() }
val positionFocusRequester = remember { FocusRequester() }
val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } }
var focused by remember { mutableStateOf(false) }
LaunchedEffect(homeRows) {
if (!focused) {
@ -241,7 +241,7 @@ fun HomePageContent(
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
.takeIf { it >= 0 }
?.let {
positionFocusRequester.tryRequestFocus()
rowFocusRequesters[it].tryRequestFocus()
delay(50)
listState.animateScrollToItem(position.row)
focused = true
@ -251,9 +251,6 @@ fun HomePageContent(
LaunchedEffect(position) {
listState.animateScrollToItem(position.row)
}
LaunchedEffect(focusedItem) {
focusedItem?.let(onUpdateBackdrop)
}
Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader(
@ -275,16 +272,7 @@ fun HomePageContent(
),
modifier =
Modifier
.focusRestorer()
.onKeyEvent {
val item = focusedItem
if (isPlayKeyUp(it) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(position, item)
return@onKeyEvent true
}
return@onKeyEvent false
},
.focusRestorer(),
) {
itemsIndexed(homeRows) { rowIndex, row ->
when (val r = row) {
@ -347,22 +335,17 @@ fun HomePageContent(
onClickItem = { index, item ->
onClickItem.invoke(RowColumn(rowIndex, index), item)
},
cardOnFocus = { isFocused, index ->
if (isFocused) {
focusedItem = row.items.getOrNull(index)
position = RowColumn(rowIndex, index)
}
},
onLongClickItem = { index, item ->
onLongClickItem.invoke(RowColumn(rowIndex, index), item)
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(rowFocusRequesters[rowIndex])
.animateItem(),
cardContent = { index, item, cardModifier, onClick, onLongClick ->
val cornerText =
remember {
remember(item) {
item?.data?.indexNumber?.let { "E$it" }
?: item
?.data
@ -385,29 +368,32 @@ fun HomePageContent(
onLongClick = onLongClick,
modifier =
cardModifier
.ifElse(
focusedItem == item,
Modifier.focusRequester(focusRequester),
).ifElse(
RowColumn(rowIndex, index) == position,
Modifier.focusRequester(
positionFocusRequester,
),
).onFocusChanged {
.onFocusChanged {
if (it.isFocused) {
position = RowColumn(rowIndex, index)
item?.let(onUpdateBackdrop)
}
if (it.isFocused && onFocusPosition != null) {
val nonEmptyRowBefore =
homeRows
.subList(0, rowIndex)
.count {
it is HomeRowLoadingState.Success && it.items.isEmpty()
}
onFocusPosition?.invoke(
onFocusPosition.invoke(
RowColumn(
rowIndex - nonEmptyRowBefore,
index,
),
)
}
}.onKeyEvent {
if (isPlayKeyUp(it) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(position, item)
return@onKeyEvent true
}
return@onKeyEvent false
},
interactionSource = null,
cardHeight = Cards.height2x3,

View file

@ -4,7 +4,6 @@ import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
@ -37,11 +36,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
@ -360,10 +359,8 @@ fun NavDrawer(
Modifier
.fillMaxHeight()
.width(drawerWidth)
.background(drawerBackground)
.onFocusChanged {
if (!it.hasFocus) {
}
.drawBehind {
drawRect(drawerBackground)
},
) {
// Even though some must be clicked, focusing on it should clear other focused items

View file

@ -1,11 +1,19 @@
package com.github.damontecres.wholphin.ui.playback
import android.content.Context
import android.hardware.display.DisplayManager
import android.view.Display
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
@ -14,13 +22,40 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.byteRateSuffixes
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.TranscodingInfo
import timber.log.Timber
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@Composable
fun PlaybackDebugOverlay(
currentPlayback: CurrentPlayback?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val display =
remember(context) {
try {
val displayManager =
context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
displayManager?.getDisplay(Display.DEFAULT_DISPLAY)
} catch (ex: Exception) {
Timber.e(ex)
null
}
}
val displayMode by produceState<String?>(null) {
while (isActive) {
value =
display?.mode?.let {
val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate)
"${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}"
}
delay(10.seconds)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
@ -38,10 +73,12 @@ fun PlaybackDebugOverlay(
add("Video Decoder:" to currentPlayback.videoDecoder)
add("Audio Decoder:" to currentPlayback.audioDecoder)
}
add("Display Mode: " to displayMode?.toString())
},
modifier = Modifier.weight(1f, fill = false),
)
currentPlayback?.transcodeInfo?.let {
TranscodeInfo(it, Modifier)
TranscodeInfo(it, Modifier.weight(2f))
}
}
}
@ -86,27 +123,21 @@ fun SimpleTable(
rows: List<Pair<String, String?>>,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) {
rows.forEach {
rows.forEach {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = it.first,
modifier = Modifier.width(100.dp),
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) {
rows.forEach {
Text(
text = it.second.toString(),
modifier = Modifier,
)
}
}

View file

@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
@ -457,9 +457,10 @@ fun PlaybackOverlay(
AsyncImage(
model = logoImageUrl,
contentDescription = "Logo",
alignment = Alignment.TopStart,
modifier =
Modifier
.sizeIn(maxWidth = 180.dp, maxHeight = 100.dp)
.size(width = 240.dp, height = 120.dp)
.padding(16.dp),
)
}

View file

@ -23,6 +23,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.size.Size
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
@ -641,10 +642,16 @@ class PlaybackViewModel
mediaSourceInfo = source,
)
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let {
refreshRateService.changeRefreshRate(it)
}
preferences.appPreferences.playbackPreferences.let { prefs ->
source.mediaStreams
?.firstOrNull { it.type == MediaStreamType.VIDEO }
?.let { stream ->
refreshRateService.changeRefreshRate(
stream = stream,
switchRefreshRate = prefs.refreshRateSwitching,
switchResolution = prefs.resolutionSwitching,
)
}
}
withContext(Dispatchers.Main) {
// TODO, don't need to release & recreate when switching streams
@ -754,7 +761,7 @@ class PlaybackViewModel
ImageRequest
.Builder(context)
.data(url)
.size(coil3.size.Size.ORIGINAL)
.size(Size.ORIGINAL)
.build(),
)
}

View file

@ -3,11 +3,10 @@ package com.github.damontecres.wholphin.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.github.damontecres.wholphin.ui.TimeFormatter
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@ -22,20 +21,28 @@ data class Clock(
/**
* The current [LocalDateTime]
*/
val now: LocalDateTime,
val now: MutableState<LocalDateTime>,
/**
* The current time formatted as a string with [TimeFormatter]
*/
val timeString: String,
val timeString: MutableState<String>,
)
@Composable
fun ProvideLocalClock(content: @Composable () -> Unit) {
var clock by remember { mutableStateOf(LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }) }
val clock =
remember {
LocalDateTime
.now()
.let { Clock(mutableStateOf(it), mutableStateOf(TimeFormatter.format(it))) }
}
LaunchedEffect(Unit) {
while (isActive) {
clock = LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }
delay(1_000)
val now = LocalDateTime.now()
val time = TimeFormatter.format(now)
clock.now.value = now
clock.timeString.value = time
delay(2_000)
}
}
CompositionLocalProvider(LocalClock provides clock, content)

View file

@ -24,6 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO
import android.content.Context
import android.graphics.Bitmap
import android.view.Surface
import java.util.concurrent.CopyOnWriteArrayList
// Wrapper for native library
@ -96,20 +97,16 @@ object MPVLib {
format: Int,
)
private val observers = mutableListOf<EventObserver>()
private val observers = CopyOnWriteArrayList<EventObserver>()
@JvmStatic
fun addObserver(o: EventObserver) {
synchronized(observers) {
observers.add(o)
}
observers.add(o)
}
@JvmStatic
fun removeObserver(o: EventObserver) {
synchronized(observers) {
observers.remove(o)
}
observers.remove(o)
}
@JvmStatic
@ -117,10 +114,8 @@ object MPVLib {
property: String,
value: Long,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
for (o in observers) {
o.eventProperty(property, value)
}
}
@ -129,10 +124,8 @@ object MPVLib {
property: String,
value: Boolean,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
for (o in observers) {
o.eventProperty(property, value)
}
}
@ -141,10 +134,8 @@ object MPVLib {
property: String,
value: Double,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
for (o in observers) {
o.eventProperty(property, value)
}
}
@ -153,28 +144,22 @@ object MPVLib {
property: String,
value: String,
) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property, value)
}
for (o in observers) {
o.eventProperty(property, value)
}
}
@JvmStatic
fun eventProperty(property: String) {
synchronized(observers) {
for (o in observers) {
o.eventProperty(property)
}
for (o in observers) {
o.eventProperty(property)
}
}
@JvmStatic
fun event(eventId: Int) {
synchronized(observers) {
for (o in observers) {
o.event(eventId)
}
for (o in observers) {
o.event(eventId)
}
}
@ -183,27 +168,21 @@ object MPVLib {
reason: Int,
error: Int,
) {
synchronized(observers) {
for (o in observers) {
o.eventEndFile(reason, error)
}
for (o in observers) {
o.eventEndFile(reason, error)
}
}
private val log_observers = mutableListOf<LogObserver>()
private val log_observers = CopyOnWriteArrayList<LogObserver>()
@JvmStatic
fun addLogObserver(o: LogObserver) {
synchronized(log_observers) {
log_observers.add(o)
}
log_observers.add(o)
}
@JvmStatic
fun removeLogObserver(o: LogObserver) {
synchronized(log_observers) {
log_observers.remove(o)
}
log_observers.remove(o)
}
@JvmStatic
@ -212,10 +191,8 @@ object MPVLib {
level: Int,
text: String,
) {
synchronized(log_observers) {
for (o in log_observers) {
o.logMessage(prefix, level, text)
}
for (o in log_observers) {
o.logMessage(prefix, level, text)
}
}

View file

@ -72,6 +72,7 @@ message PlaybackPreferences {
PlayerBackend player_backend = 20;
MpvOptions mpv_options = 21;
bool refresh_rate_switching = 22;
bool resolution_switching = 23;
}
message HomePagePreferences{

View file

@ -398,6 +398,7 @@
<string name="color_code_programs">Color-code programs</string>
<string name="subtitle_delay">Subtitle delay</string>
<string name="backdrop_display">Backdrop style</string>
<string name="resolution_switching">Resolution switching</string>
<string name="discover">Discover</string>
<string name="request">Request</string>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system"/>
<certificates src="user"/>
</trust-anchors>
</base-config>
</network-security-config>

View file

@ -0,0 +1,124 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.services.DisplayMode
import com.github.damontecres.wholphin.services.RefreshRateService
import org.junit.Assert
import org.junit.Test
class TestDisplayModeChoice {
companion object {
val HD_60 = DisplayMode(0, 1920, 1080, 60f)
val HD_30 = DisplayMode(1, 1920, 1080, 30f)
val HD_24 = DisplayMode(2, 1920, 1080, 24f)
val UHD_60 = DisplayMode(3, 3840, 2160, 60f)
val UHD_30 = DisplayMode(4, 3840, 2160, 30f)
val UHD_24 = DisplayMode(5, 3840, 2160, 24f)
val ALL_MODES = listOf(UHD_24, UHD_30, UHD_60, HD_24, HD_30, HD_60)
}
@Test
fun test1() {
val streamWidth = 1920
val streamHeight = 1080
val streamRealFrameRate = 60f
val result =
RefreshRateService.findDisplayMode(
displayModes = ALL_MODES,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(3, result?.modeId)
}
@Test
fun test2() {
val streamWidth = 1920
val streamHeight = 1080
val streamRealFrameRate = 60f
val result =
RefreshRateService.findDisplayMode(
displayModes = ALL_MODES,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true,
resolutionSwitch = true,
)
Assert.assertEquals(0, result?.modeId)
}
@Test
fun test3() {
val streamWidth = 1920
val streamHeight = 1080
val streamRealFrameRate = 30f
val result =
RefreshRateService.findDisplayMode(
displayModes = ALL_MODES,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(4, result?.modeId)
}
@Test
fun test4() {
val streamWidth = 1920
val streamHeight = 804
val streamRealFrameRate = 30f
val result =
RefreshRateService.findDisplayMode(
displayModes = ALL_MODES,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = false,
resolutionSwitch = true,
)
Assert.assertEquals(1, result?.modeId)
}
@Test
fun testFraction() {
val streamWidth = 1920
val streamHeight = 1080
val streamRealFrameRate = 30f
val displayModes =
listOf(
DisplayMode(0, 1920, 1080, 59.940f),
DisplayMode(1, 1920, 1080, 60f),
// DisplayMode(2, 1920, 1080, 29.970f),
)
val result =
RefreshRateService.findDisplayMode(
displayModes = displayModes,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = 29.970f,
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(0, result?.modeId)
val result2 =
RefreshRateService.findDisplayMode(
displayModes = displayModes,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = 24f,
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(1, result2?.modeId)
}
}

View file

@ -104,6 +104,16 @@ class TestStreamChoiceServiceBasic(
),
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED),
),
TestInput(
1,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", forced = true, default = true),
subtitle(1, "eng", false),
subtitle(2, "eng", false),
),
),
)
}
}