Fix showing live tv programs on home page (#1504)

## Description
Fixes the inconsistent live tv programs shown on the home page by using
a different API endpoint & filtering.

Also some UI fixes:
- Use the "Watch live" string consistently for the row instead of it
sometimes switching to "Live TV"
- Adjust the "Ends at" in the home page header to show the actual end
time
- Show the channel name in "quick details"
- Show season & episode number, if applicable, in "quick details"

### Related issues
Fixes #1080

### Testing
Nvidia shield w/ fake EPG data

## Screenshots
N/A, just UI fixes, nothing new

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-06-03 12:40:53 -04:00 • committed by GitHub
parent 6c64470c28
commit 5ae452155e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 88 additions and 25 deletions

View file

@ -92,7 +92,12 @@ data class BaseItem(
val favorite get() = data.userData?.isFavorite ?: false val favorite get() = data.userData?.isFavorite ?: false
val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks val timeRemainingOrRuntime: Duration?
get() =
when (type) {
BaseItemKind.PROGRAM -> null
else -> data.timeRemaining ?: data.runTimeTicks?.ticks
}
/** /**
* Contains pre computed UI elements that would be expensive to create on the main thread * Contains pre computed UI elements that would be expensive to create on the main thread
@ -137,6 +142,17 @@ data class BaseItem(
} else if (type == BaseItemKind.BOX_SET) { } else if (type == BaseItemKind.BOX_SET) {
data.productionYear?.let { add(it.toString()) } data.productionYear?.let { add(it.toString()) }
data.childCount?.let { add("$it items") } data.childCount?.let { add("$it items") }
} else if (type == BaseItemKind.PROGRAM) {
data.channelName?.let(::add)
if (data.isSeries == true) {
// TV episode
data.seasonEpisode?.let(::add)
data.premiereDate?.let {
add(getDateFormatter().format(it))
}
} else {
data.productionYear?.let { add(it.toString()) }
}
} else { } else {
data.productionYear?.let { add(it.toString()) } data.productionYear?.let { add(it.toString()) }
} }

View file

@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.ProgramItemFields
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.main.settings.Library
@ -30,6 +31,7 @@ import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.GetProgramsDtoHandler
import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler
import com.github.damontecres.wholphin.util.GetStudiosRequestHandler import com.github.damontecres.wholphin.util.GetStudiosRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
@ -54,9 +56,11 @@ import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
@ -66,7 +70,6 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
import org.jellyfin.sdk.model.api.request.GetStudiosRequest import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber import timber.log.Timber
@ -281,7 +284,7 @@ class HomeSettingsService
if (it.collectionType == CollectionType.LIVETV) { if (it.collectionType == CollectionType.LIVETV) {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = index, id = index,
title = ResStringProvider(R.string.live_tv), title = ResStringProvider(R.string.watch_live),
config = HomeRowConfig.TvPrograms(), config = HomeRowConfig.TvPrograms(),
) )
} else { } else {
@ -367,7 +370,7 @@ class HomeSettingsService
if (userDto.tvAccess) { if (userDto.tvAccess) {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id++, id = id++,
title = ResStringProvider(R.string.live_tv), title = ResStringProvider(R.string.watch_live),
config = HomeRowConfig.TvPrograms(), config = HomeRowConfig.TvPrograms(),
) )
} else { } else {
@ -520,7 +523,7 @@ class HomeSettingsService
is HomeRowConfig.TvPrograms -> { is HomeRowConfig.TvPrograms -> {
HomeRowConfigDisplay( HomeRowConfigDisplay(
id = id, id = id,
title = ResStringProvider(R.string.live_tv), title = ResStringProvider(R.string.watch_live),
config, config,
) )
} }
@ -1037,29 +1040,39 @@ class HomeSettingsService
is HomeRowConfig.TvPrograms -> { is HomeRowConfig.TvPrograms -> {
val request = val request =
GetRecommendedProgramsRequest( GetProgramsDto(
userId = userDto.id, userId = userDto.id,
fields = DefaultItemFields, fields = ProgramItemFields,
limit = limit, limit = limit,
enableUserData = true, enableUserData = true,
enableImages = true, enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
imageTypeLimit = 1, imageTypeLimit = 1,
isAiring = true,
minEndDate = DateTime.now().plusMinutes(1),
) )
// paging not supported if (usePaging) {
api.liveTvApi ApiRequestPager(
.getRecommendedPrograms(request) api,
.content.items request,
.map { BaseItem(it, row.viewOptions.useSeries) } GetProgramsDtoHandler,
.let { scope,
Success( useSeriesForPrimary = row.viewOptions.useSeries,
ResStringProvider(R.string.live_tv), ).init()
it, } else {
row.viewOptions, api.liveTvApi
rowType = row, .getPrograms(request)
showViewMore = it.size >= limit, .content.items
) .map { BaseItem(it, row.viewOptions.useSeries) }
} }.let {
Success(
ResStringProvider(R.string.watch_live),
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
} }
is HomeRowConfig.TvChannels -> { is HomeRowConfig.TvChannels -> {

View file

@ -83,6 +83,8 @@ val PhotoItemFields =
ItemFields.HEIGHT, ItemFields.HEIGHT,
) )
val ProgramItemFields = DefaultItemFields + listOf(ItemFields.CHANNEL_INFO)
object Cards { object Cards {
const val HEIGHT_2X3_DP = 172 const val HEIGHT_2X3_DP = 172
val height2x3 = HEIGHT_2X3_DP.dp val height2x3 = HEIGHT_2X3_DP.dp

View file

@ -11,7 +11,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@ -29,6 +28,7 @@ import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.getTimeFormatter import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
import org.jellyfin.sdk.model.DateTime
import kotlin.time.Duration import kotlin.time.Duration
@Composable @Composable
@ -37,6 +37,7 @@ fun QuickDetails(
timeRemaining: Duration?, timeRemaining: Duration?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall, textStyle: TextStyle = MaterialTheme.typography.titleSmall,
endsAt: DateTime? = null,
) { ) {
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
val inlineContentMap = rememberQuickDetailsContentMap(textStyle) val inlineContentMap = rememberQuickDetailsContentMap(textStyle)
@ -53,7 +54,11 @@ fun QuickDetails(
QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap) QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
} }
} }
timeRemaining?.let { TimeRemaining(it, textStyle = textStyle) } if (timeRemaining != null) {
TimeRemaining(timeRemaining, textStyle = textStyle)
} else if (endsAt != null) {
EndsAt(endsAt, textStyle = textStyle)
}
} }
} }
@ -150,3 +155,26 @@ fun TimeRemaining(
modifier = modifier, modifier = modifier,
) )
} }
@Composable
fun EndsAt(
endsAt: DateTime,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
) {
val resources = LocalResources.current
val remainingStr =
remember(endsAt, resources) {
val endTimeStr = getTimeFormatter().format(endsAt)
buildAnnotatedString {
dot()
append(resources.getString(R.string.ends_at, endTimeStr))
}
}
Text(
text = remainingStr,
style = textStyle,
maxLines = 1,
modifier = modifier,
)
}

View file

@ -300,6 +300,7 @@ fun SeerrDiscoverPage(
overviewTwoLines = true, overviewTwoLines = true,
quickDetails = details, quickDetails = details,
timeRemaining = null, timeRemaining = null,
endsAt = null,
showLogo = preferences.appPreferences.interfacePreferences.showLogos, showLogo = preferences.appPreferences.interfacePreferences.showLogos,
logoImageUrl = null, // TODO logoImageUrl = null, // TODO
modifier = modifier =

View file

@ -88,6 +88,7 @@ import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.DateTime
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber import timber.log.Timber
@ -550,6 +551,7 @@ fun HomePageHeader(
overviewTwoLines = isEpisode, overviewTwoLines = isEpisode,
quickDetails = item?.ui?.quickDetails, quickDetails = item?.ui?.quickDetails,
timeRemaining = item?.timeRemainingOrRuntime, timeRemaining = item?.timeRemainingOrRuntime,
endsAt = item?.data?.endDate,
showLogo = showLogo, showLogo = showLogo,
logoImageUrl = rememberLogoUrl(item), logoImageUrl = rememberLogoUrl(item),
modifier = modifier, modifier = modifier,
@ -564,6 +566,7 @@ fun HomePageHeader(
overviewTwoLines: Boolean, overviewTwoLines: Boolean,
quickDetails: QuickDetailsData?, quickDetails: QuickDetailsData?,
timeRemaining: Duration?, timeRemaining: Duration?,
endsAt: DateTime?,
showLogo: Boolean, showLogo: Boolean,
logoImageUrl: String?, logoImageUrl: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -587,7 +590,7 @@ fun HomePageHeader(
if (subtitle != null) { if (subtitle != null) {
EpisodeName(subtitle) EpisodeName(subtitle)
} }
QuickDetails(quickDetails, timeRemaining) QuickDetails(quickDetails, timeRemaining, endsAt = endsAt)
val overviewModifier = val overviewModifier =
Modifier Modifier
.padding(0.dp) .padding(0.dp)

View file

@ -136,7 +136,7 @@ enum class LibraryRowType(
GENRES(R.string.genres), GENRES(R.string.genres),
STUDIOS(R.string.studios), STUDIOS(R.string.studios),
TV_CHANNELS(R.string.channels), TV_CHANNELS(R.string.channels),
TV_PROGRAMS(R.string.live_tv), TV_PROGRAMS(R.string.watch_live),
RECENTLY_RECORDED(R.string.recently_recorded), RECENTLY_RECORDED(R.string.recently_recorded),
COLLECTION(R.string.collection), COLLECTION(R.string.collection),
PLAYLIST(R.string.playlist), PLAYLIST(R.string.playlist),