From aa194f5a2df96549c3ea479ca35bb3fe3eae3064 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:14:14 -0500 Subject: [PATCH 1/8] Fix duplicates being added to Android TV watch next channel (#588) ## Description Fixes the logic for determining which items need to be added to the watch next channel row Also adds some code to clean up existing duplicates. This code can be removed around the next release. Adds a channel to the home screen for recently added media. This is needed to ensure full integration with the OS to allow toggling off any of the Wholphin channels including watch next. ### Related issues #372 introduced the bug Fixes #587 --- .../services/tvprovider/TvProviderWorker.kt | 166 +++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 1d54f256..134e4903 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -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" From 622a99631bd51872774d67cddca1a52754717592 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:35:42 -0500 Subject: [PATCH 2/8] Fix movie page library & collection tabs not updating (#594) ## Description Ensures the tabs on movie page use separate view models so the data isn't shared between them. ### Related issues Fixes #591 --- .../wholphin/ui/components/CollectionFolderGrid.kt | 5 ++++- .../damontecres/wholphin/ui/detail/CollectionFolderMovie.kt | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index a2e99ffe..eef5c9ae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -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( - key = itemId, + key = viewModelKey, ) { it.create( itemId = itemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index f824a4b9..1292cd5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -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 = From 243e08b635f2ec7f97e95005790b68b454744d64 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:36:05 -0500 Subject: [PATCH 3/8] Some minor optimizations to improve UI speed (#593) ## Description Many tiny changes that improve UI rendering speed and reduces recompositions Still plenty of room for improvement though. --- .../wholphin/data/model/BaseItem.kt | 2 + .../damontecres/wholphin/ui/cards/ItemRow.kt | 60 +------------------ .../wholphin/ui/components/MovieComponents.kt | 3 +- .../ui/components/SeriesComponents.kt | 3 +- .../wholphin/ui/components/TimeDisplay.kt | 4 +- .../wholphin/ui/detail/PlaylistDetails.kt | 2 +- .../damontecres/wholphin/ui/main/HomePage.kt | 58 +++++++----------- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 9 +-- .../wholphin/ui/util/LocalClock.kt | 21 ++++--- .../wholphin/test/TestStreamChoiceService.kt | 10 ++++ 10 files changed, 62 insertions(+), 110 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index e5cd53bc..599b7638 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 618b9003..c03a373a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -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 ItemRow( @@ -39,13 +32,10 @@ fun 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 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 ItemRow( } } } - -@Composable -fun BannerItemRow( - title: String, - items: List, - 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, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt index e25f50be..6d9984dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt @@ -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 { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt index de79bd5a..3939df91 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -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 { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt index 8fa6f60f..f5791e04 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 150228a5..4907d71e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 05519d8c..c838733a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -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,18 +335,13 @@ 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 = @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index ef858eb7..ff3a64b3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -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 @@ -346,10 +345,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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt index 53cd4d9b..5c5d2a60 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -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, /** * The current time formatted as a string with [TimeFormatter] */ - val timeString: String, + val timeString: MutableState, ) @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) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt index fa16c5ab..7d551049 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt @@ -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), + ), + ), ) } } From e3fa45d12f201aba480a844fad9369153a40cdd5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:39:07 -0500 Subject: [PATCH 4/8] Trigger tv guide scroll animation during key presses (#595) ## Description Instead of triggering the tv guide scroll animation during composition, do it during key presses. This fixes an edge case that crashes the main thread when returning the guide but the underlying UI data structures haven't been updated. ### Related issues Fixes #592 --- .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 68c62063..037a5ae7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -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.. + (programs.range.first.. Date: Tue, 30 Dec 2025 18:39:48 -0500 Subject: [PATCH 5/8] A few more UI tweaks (#603) ## Description - Use series poster for seasons without a poster - Larger logo on playback overlay - Fix an issue where corner text on card on the home page would not properly update ### Related issues Closes #590 Closes #601 Fixes https://github.com/damontecres/Wholphin/pull/586#issuecomment-3698613927 --- .../damontecres/wholphin/services/ImageUrlService.kt | 9 +++++++++ .../wholphin/ui/components/GenreCardGrid.kt | 11 ++++++----- .../github/damontecres/wholphin/ui/main/HomePage.kt | 2 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 5 +++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index cab8609e..39967e24 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -28,6 +28,7 @@ class ImageUrlService itemType: BaseItemKind, seriesId: UUID?, useSeriesForPrimary: Boolean, + imageTags: Map, 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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index c84045b3..ec8feca0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -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, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index c838733a..31a4ecf0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -345,7 +345,7 @@ fun HomePageContent( .animateItem(), cardContent = { index, item, cardModifier, onClick, onLongClick -> val cornerText = - remember { + remember(item) { item?.data?.indexNumber?.let { "E$it" } ?: item ?.data diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 74821924..99313af2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -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), ) } From 7e5a491d8e01e66cfbad47c0d451dd168fdfe0fc Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:13:07 -0500 Subject: [PATCH 6/8] Allow trusting user provided SSL/TLS certificates (#608) ## Description Allow for trusting user added certificates ### Related issues Closes #254 --- app/src/main/AndroidManifest.xml | 3 ++- app/src/main/res/xml/network_security_config.xml | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9edf3067..8a97bc36 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,7 +32,8 @@ android:supportsRtl="true" android:theme="@style/Theme.Wholphin" android:name=".WholphinApplication" - android:usesCleartextTraffic="true"> + android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config"> + + + + + + + + From 38697b011d7dbb4f272624c611b5e97f7190384d Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:10:29 -0500 Subject: [PATCH 7/8] Switch from synchronized mutations to copy-on-write (#609) ## Description Instead of synchronizing all access to event observers, use a `CopyOnWriteArrayList`. This means sending events doesn't need to lock. --- .../damontecres/wholphin/util/mpv/MPVLib.kt | 69 +++++++------------ 1 file changed, 23 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt index acb2f8d8..e5b902d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt @@ -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() + private val observers = CopyOnWriteArrayList() @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() + private val log_observers = CopyOnWriteArrayList() @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) } } From 26a913b05ec67eca06692c3a347b80d111899b3a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 31 Dec 2025 17:11:30 -0500 Subject: [PATCH 8/8] Support resolution switching (#597) ## Description Adds a settings for automatic resolution switching. When enabled, the app tries to output content at the video stream's resolution. For example, if playing 1080p content on a 4K TV, the actual output by the Android TV device will be 1080p. This works in hand-in-hand with automatic refresh rate switching. So enabling both allows for the output to match both refresh rate and content resolution. Outputting the best refresh rate is preferred over best resolution. Also, shows the current display mode in the playback debug info. ### Related issues Closes #428 Fixes #531 --- app/src/main/AndroidManifest.xml | 2 +- .../damontecres/wholphin/MainActivity.kt | 14 +- .../wholphin/preferences/AppPreference.kt | 21 ++- .../preferences/AppPreferencesSerializer.kt | 1 + .../wholphin/services/RefreshRateService.kt | 119 ++++++++++++++--- .../wholphin/ui/detail/DebugPage.kt | 2 +- .../ui/playback/PlaybackDebugOverlay.kt | 61 ++++++--- .../wholphin/ui/playback/PlaybackViewModel.kt | 17 ++- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 1 + .../wholphin/test/TestDisplayModeChoice.kt | 124 ++++++++++++++++++ 11 files changed, 319 insertions(+), 44 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a97bc36..cb130157 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,7 +38,7 @@ 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"> diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index c21aa161..4e6e22b0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -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 @@ -114,12 +115,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() @@ -318,6 +319,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") diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 6bca9d38..4b08b226 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -696,7 +696,25 @@ sealed interface AppPreference { 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( + 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, @@ -934,6 +952,7 @@ val advancedPreferences = AppPreference.GlobalContentScale, AppPreference.MaxBitrate, AppPreference.RefreshRateSwitching, + AppPreference.ResolutionSwitching, AppPreference.PlaybackDebugInfo, ), ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index c39b9012..f84744c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -52,6 +52,7 @@ class AppPreferencesSerializer playerBackend = AppPreference.PlayerBackendPref.defaultValue refreshRateSwitching = AppPreference.RefreshRateSwitching.defaultValue + resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue overrides = PlaybackOverrides diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index e34d2fa1..67b541fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -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(originalMode) - val refreshRateMode: LiveData = _refreshRateMode + private val displayModes: List by lazy { + display.supportedModes + .orEmpty() + .map { DisplayMode(it) } + .sortedWith( + compareByDescending({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) + } + + private val _refreshRateMode = EqualityMutableLiveData(originalMode.modeId) + val refreshRateMode: LiveData = _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, + 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, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 60ad839e..f0ad2c0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -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(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt index 496166c3..ce31d865 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt @@ -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(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>, 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, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 1d380e08..39cee993 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -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(), ) } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index f10b49c5..e4cd52d7 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -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{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47122cca..d4e4a859 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -398,6 +398,7 @@ Color-code programs Subtitle delay Backdrop style + Resolution switching Disabled diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt new file mode 100644 index 00000000..7af1bfce --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt @@ -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) + } +}