From 510ca416f3d78b692b72f13d5cc9bab1a7d07ed3 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 30 Jan 2026 17:10:44 -0500 Subject: [PATCH 1/7] Release v0.4.1 From 7aa4ccc447f4e404059b8cf57a3c9f96173fed3b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:28:41 -0500 Subject: [PATCH 2/7] Update README Removed versioning details and simplified media player options. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 50f01c12..b4121cb9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a different app UI that's inspired by Plex for users interested in migrating to Jellyfin. -This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental). +This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin supports playing media using either ExoPlayer or MPV.

From e6ff173b41417a18aca56bd6af8c826966719bc7 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 2 Feb 2026 10:34:30 -0500 Subject: [PATCH 3/7] Adopt jellyfin's AI/LLM policy --- .github/pull_request_template.md | 13 +++++++++---- CONTRIBUTING.md | 7 +++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index eae1ff9c..3043d994 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,12 +1,17 @@ + + ## Description ### Related issues - + -### Screenshots +### Testing + + +## Screenshots -### AI/LLM usage - +## AI or LLM usage + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c8484fa..3eee4ee9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,3 +23,10 @@ You acknowledge that, if applicable, submitting and subsequent acceptance of any Please be thoughtful when contributing code and consider the long term maintenance/support aspect for any new features. See the [developer's guide](./DEVELOPMENT.md) for more information. + + +#### Policy for AI or LLMs + +Wholphin has adopted the [Jellyfin LLM/"AI" Development Policy](https://jellyfin.org/docs/general/contributing/llm-policies/). AI/LLM assisted code contributions are permitted under the policy, but the author must undertand and be able to explain the code changes in their own words. + +At a minimum, you must disclose the usage any of AI/LLM assistance and you must write the pull request description _without_ AI assistance. From ae5035703fd92ed1a6fdcd20da5907516711ac6a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:25:16 -0500 Subject: [PATCH 4/7] Merge pull request #817 from damontecres/fix/805 Always use space for home page header --- .../damontecres/wholphin/ui/main/HomePage.kt | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) 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 d866926d..4c58b00c 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 @@ -401,19 +401,17 @@ fun HomePageHeader( item: BaseItem?, modifier: Modifier = Modifier, ) { - item?.let { - val isEpisode = item.type == BaseItemKind.EPISODE - val dto = item.data - HomePageHeader( - title = item.title, - subtitle = if (isEpisode) dto.name else null, - overview = dto.overview, - overviewTwoLines = isEpisode, - quickDetails = item.ui.quickDetails, - timeRemaining = item.timeRemainingOrRuntime, - modifier = modifier, - ) - } + val isEpisode = item?.type == BaseItemKind.EPISODE + val dto = item?.data + HomePageHeader( + title = item?.title, + subtitle = if (isEpisode) dto?.name else null, + overview = dto?.overview, + overviewTwoLines = isEpisode, + quickDetails = item?.ui?.quickDetails, + timeRemaining = item?.timeRemainingOrRuntime, + modifier = modifier, + ) } @Composable @@ -422,7 +420,7 @@ fun HomePageHeader( subtitle: String?, overview: String?, overviewTwoLines: Boolean, - quickDetails: AnnotatedString, + quickDetails: AnnotatedString?, timeRemaining: Duration?, modifier: Modifier = Modifier, ) { @@ -450,7 +448,7 @@ fun HomePageHeader( subtitle?.let { EpisodeName(it) } - QuickDetails(quickDetails, timeRemaining) + QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining) val overviewModifier = Modifier .padding(0.dp) From 045b689994b9e458af9f372224368a71dc431e1e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:11:37 -0500 Subject: [PATCH 5/7] Add ability to sort and filter items in a playlist (#818) ## Description Add ability to sort and filter items in a playlist. There's a new button for each added to the playlist details page. Starting playback will run in the order selected. The sort and filter are persisted locally per playlist, so returning to the playlist will remember the previous settings. Note: playlist playback is still limited to 100 items (#42) ### Related issues Closes #285 ### Testing Emulator & nvidia shield ## Screenshots ![playlist_sort_filter Large](https://github.com/user-attachments/assets/9880021b-a1c3-4d8d-ace2-83737f3f772a) ## AI or LLM usage None --- .../wholphin/data/filter/ItemFilterBy.kt | 11 + .../wholphin/services/PlaylistCreator.kt | 40 +- .../ui/components/CollectionFolderGrid.kt | 93 +--- .../wholphin/ui/detail/PlaylistDetails.kt | 486 ++++++++++++------ .../wholphin/ui/util/FilterUtils.kt | 113 ++++ 5 files changed, 497 insertions(+), 246 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt index 92dbecf3..fec0fb8d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -39,6 +39,17 @@ val DefaultForGenresFilterOptions = DecadeFilter, ) +val DefaultPlaylistItemsOptions = + listOf( + PlayedFilter, + FavoriteFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + sealed interface ItemFilterBy { @get:StringRes val stringRes: Int diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 824342d5..4231d9b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -17,7 +17,6 @@ import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler import com.github.damontecres.wholphin.util.TransformList import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -34,7 +33,6 @@ import org.jellyfin.sdk.model.api.PlaylistUserPermissions import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import javax.inject.Inject @@ -79,19 +77,22 @@ class PlaylistCreator suspend fun createFromPlaylistId( playlistId: UUID, startIndex: Int?, - shuffled: Boolean, + sortAndDirection: SortAndDirection, + filter: GetItemsFilter, ): Playlist { val request = - GetPlaylistItemsRequest( - playlistId = playlistId, - fields = DefaultItemFields, - startIndex = startIndex, - limit = Playlist.MAX_SIZE, + filter.applyTo( + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + parentId = playlistId, + fields = DefaultItemFields, + startIndex = startIndex, + limit = Playlist.MAX_SIZE, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), ) - var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items - if (shuffled) { - items = items.shuffled() - } + val items = GetItemsRequestHandler.execute(api, request).content.items return Playlist(items.convertAndAddParts(), 0) } @@ -206,9 +207,18 @@ class PlaylistCreator BaseItemKind.PLAYLIST -> { PlaylistCreationResult.Success( createFromPlaylistId( - item.id, - startIndex, - shuffled, + playlistId = item.id, + startIndex = startIndex, + sortAndDirection = + if (shuffled) { + SortAndDirection(ItemSortBy.RANDOM, SortOrder.ASCENDING) + } else { + sortAndDirection ?: SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ) + }, + filter = filter, ), ) } 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 3fa8ffa5..29991126 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 @@ -47,18 +47,9 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter -import com.github.damontecres.wholphin.data.filter.DecadeFilter import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions -import com.github.damontecres.wholphin.data.filter.FavoriteFilter import com.github.damontecres.wholphin.data.filter.FilterValueOption -import com.github.damontecres.wholphin.data.filter.FilterVideoType -import com.github.damontecres.wholphin.data.filter.GenreFilter import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter -import com.github.damontecres.wholphin.data.filter.PlayedFilter -import com.github.damontecres.wholphin.data.filter.VideoTypeFilter -import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -88,6 +79,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler @@ -103,9 +95,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.genresApi -import org.jellyfin.sdk.api.client.extensions.localizationApi -import org.jellyfin.sdk.api.client.extensions.yearsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType @@ -116,7 +105,6 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber -import java.util.TreeSet import java.util.UUID import kotlin.time.Duration @@ -391,79 +379,12 @@ class CollectionFolderViewModel } suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = - try { - when (filterOption) { - GenreFilter -> { - api.genresApi - .getGenres( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - ).content.items - .map { FilterValueOption(it.name ?: "", it.id) } - } - - FavoriteFilter, - PlayedFilter, - -> { - listOf( - FilterValueOption("True", null), - FilterValueOption("False", null), - ) - } - - OfficialRatingFilter -> { - api.localizationApi.getParentalRatings().content.map { - FilterValueOption(it.name ?: "", it.value) - } - } - - VideoTypeFilter -> { - FilterVideoType.entries.map { - FilterValueOption(it.readable, it) - } - } - - YearFilter -> { - api.yearsApi - .getYears( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNull { - it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } - } - } - - DecadeFilter -> { - val items = TreeSet() - api.yearsApi - .getYears( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNullTo(items) { - it.name - ?.toIntOrNull() - ?.div(10) - ?.times(10) - } - items.toList().sorted().map { FilterValueOption("$it's", it) } - } - - CommunityRatingFilter -> { - (1..10).map { - FilterValueOption("$it", it) - } - } - } - } catch (ex: Exception) { - Timber.e(ex, "Exception get filter value options for $filterOption") - listOf() - } + FilterUtils.getFilterOptionValues( + api, + serverRepository.currentUser.value?.id, + itemUuid, + filterOption, + ) suspend fun positionOfLetter(letter: Char): Int? = withContext(Dispatchers.IO) { 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 5bf996a3..7e552525 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 @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -22,7 +23,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable 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 @@ -49,7 +52,14 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.DefaultPlaylistItemsOptions +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.DefaultItemFields @@ -61,26 +71,39 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import com.github.damontecres.wholphin.ui.components.FilterByButton import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.SortByButton +import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber import java.util.UUID import javax.inject.Inject import kotlin.time.Duration @@ -92,9 +115,22 @@ class PlaylistViewModel api: ApiClient, val navigationManager: NavigationManager, private val backdropService: BackdropService, + private val serverRepository: ServerRepository, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Pending) val items = MutableLiveData>(listOf()) + val filterAndSort = + MutableStateFlow( + FilterAndSort( + filter = GetItemsFilter(), + sortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + ), + ) fun init(playlistId: UUID) { loading.value = LoadingState.Loading @@ -103,19 +139,93 @@ class PlaylistViewModel LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), ) { val playlist = fetchItem(playlistId) - val request = - GetPlaylistItemsRequest( - playlistId = playlist.id, - fields = DefaultItemFields, + val libraryDisplayInfo = + serverRepository.currentUser.value?.let { user -> + libraryDisplayInfoDao.getItem(user, itemId) + } + val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() + val sortAndDirection = + libraryDisplayInfo?.sortAndDirection ?: SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, ) - val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init() - withContext(Dispatchers.Main) { - items.value = pager - loading.value = LoadingState.Success + loadItems(filter, sortAndDirection) + } + } + + fun loadItems( + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ) { + viewModelScope.launchIO { + backdropService.clearBackdrop() + loading.setValueOnMain(LoadingState.Loading) + this@PlaylistViewModel.filterAndSort.update { + FilterAndSort(filter, sortAndDirection) + } + + serverRepository.currentUser.value?.let { user -> + val playlistId = item.value!!.id + viewModelScope.launchIO { + val libraryDisplayInfo = + libraryDisplayInfoDao.getItem(user, itemId)?.copy( + filter = filter, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + ) + ?: LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + filter = filter, + viewOptions = null, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + + val request = + filter.applyTo( + GetItemsRequest( + parentId = playlistId, + userId = user.id, + fields = DefaultItemFields, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), + ) + try { + val pager = + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + ).init() + + withContext(Dispatchers.Main) { + items.value = pager + loading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching playlist %s", itemId) + withContext(Dispatchers.Main) { + items.value = listOf() + loading.value = LoadingState.Error(ex) + } + } } } } + suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = + FilterUtils.getFilterOptionValues( + api, + serverRepository.currentUser.value?.id, + itemUuid, + filterOption, + ) + fun updateBackdrop(item: BaseItem) { viewModelScope.launchIO { backdropService.submit(item) @@ -123,6 +233,12 @@ class PlaylistViewModel } } +@Immutable +data class FilterAndSort( + val filter: GetItemsFilter, + val sortAndDirection: SortAndDirection, +) + @Composable fun PlaylistDetails( destination: Destination.MediaItem, @@ -136,78 +252,75 @@ fun PlaylistDetails( val loading by viewModel.loading.observeAsState(LoadingState.Pending) val playlist by viewModel.item.observeAsState(null) val items by viewModel.items.observeAsState(listOf()) + val filterAndSort by viewModel.filterAndSort.collectAsState() var longClickDialog by remember { mutableStateOf(null) } - when (val st = loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) - } + val goToString = stringResource(R.string.go_to) + val playFromHereString = stringResource(R.string.play_from_here) - LoadingState.Pending, LoadingState.Loading -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - playlist?.let { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - PlaylistDetailsContent( - playlist = it, - items = items, - focusRequester = focusRequester, - onChangeBackdrop = viewModel::updateBackdrop, - onClickIndex = { index, _ -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = index, - shuffle = false, - ), - ) - }, - onClickPlay = { shuffle -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = 0, - shuffle = shuffle, - ), - ) - }, - onLongClickIndex = { index, item -> - longClickDialog = - DialogParams( - fromLongClick = true, - title = item.name ?: "", - items = - listOf( - DialogItem( - context.getString(R.string.go_to), - Icons.Default.ArrowForward, - ) { - viewModel.navigationManager.navigateTo(item.destination()) - }, - DialogItem( - context.getString(R.string.play_from_here), - Icons.Default.PlayArrow, - ) { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = index, - shuffle = false, - ), - ) - }, + PlaylistDetailsContent( + loadingState = loading, + playlist = playlist, + items = items, + onChangeBackdrop = viewModel::updateBackdrop, + onClickIndex = { index, _ -> + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = index, + shuffle = false, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, + ), + ) + }, + onClickPlay = { shuffle -> + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = 0, + shuffle = shuffle, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, + ), + ) + }, + onLongClickIndex = { index, item -> + longClickDialog = + DialogParams( + fromLongClick = true, + title = item.name ?: "", + items = + listOf( + DialogItem( + goToString, + Icons.Default.ArrowForward, + ) { + viewModel.navigationManager.navigateTo(item.destination()) + }, + DialogItem( + playFromHereString, + Icons.Default.PlayArrow, + ) { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = index, + shuffle = false, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, ), - ) - }, - modifier = modifier, + ) + }, + ), ) - } - } - } + }, + filterAndSort = filterAndSort, + onFilterAndSortChange = viewModel::loadItems, + getPossibleFilterValues = viewModel::getFilterOptionValues, + modifier = modifier, + ) longClickDialog?.let { params -> DialogPopup( params = params, @@ -218,14 +331,17 @@ fun PlaylistDetails( @Composable fun PlaylistDetailsContent( - playlist: BaseItem, + playlist: BaseItem?, items: List, onClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit, onChangeBackdrop: (BaseItem) -> Unit, + filterAndSort: FilterAndSort, + onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + loadingState: LoadingState, modifier: Modifier = Modifier, - focusRequester: FocusRequester = remember { FocusRequester() }, ) { var savedIndex by rememberSaveable { mutableIntStateOf(0) } var focusedIndex by remember { mutableIntStateOf(savedIndex) } @@ -234,6 +350,12 @@ fun PlaylistDetailsContent( LaunchedEffect(focusedItem) { focusedItem?.let(onChangeBackdrop) } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(loadingState) { + if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) { + focusRequester.tryRequestFocus() + } + } val playButtonFocusRequester = remember { FocusRequester() } @@ -248,83 +370,123 @@ fun PlaylistDetailsContent( .fillMaxSize(), ) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(32.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 8.dp) .fillMaxWidth(), ) { PlaylistDetailsHeader( focusedItem = focusedItem, onClickPlay = onClickPlay, playButtonFocusRequester = playButtonFocusRequester, + focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() }, + filterAndSort = filterAndSort, + onFilterAndSortChange = onFilterAndSortChange, + getPossibleFilterValues = getPossibleFilterValues, modifier = Modifier - .padding(start = 16.dp) + .padding(start = 16.dp, top = 80.dp) .fillMaxWidth(.25f), ) - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Text( - text = playlist.name ?: stringResource(R.string.playlist), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displayMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .padding(bottom = 32.dp) - .fillMaxHeight() -// .fillMaxWidth(.8f) - .weight(1f) - .background( - MaterialTheme.colorScheme - .surfaceColorAtElevation(1.dp) - .copy(alpha = .75f), - shape = RoundedCornerShape(16.dp), - ).focusRequester(focusRequester) - .focusGroup() - .focusRestorer(focus), - ) { - itemsIndexed(items) { index, item -> - PlaylistItem( - item = item, - index = index, - onClick = { - savedIndex = index - item?.let { - onClickIndex.invoke(index, item) - } - }, - onLongClick = { - savedIndex = index - item?.let { - onLongClickIndex.invoke(index, item) - } - }, - modifier = - Modifier - .height(80.dp) - .ifElse( - index == savedIndex, - Modifier.focusRequester(focus), - ).onFocusChanged { - if (it.isFocused) { - focusedIndex = index - } - }.focusProperties { - left = playButtonFocusRequester - previous = playButtonFocusRequester - }, + when (loadingState) { + is LoadingState.Error -> { + ErrorMessage(loadingState, modifier) + } + + LoadingState.Pending, LoadingState.Loading -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = playlist?.name ?: stringResource(R.string.playlist), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displayMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) + if (items.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .padding(bottom = 32.dp) + .fillMaxHeight() +// .fillMaxWidth(.8f) + .weight(1f) + .background( + MaterialTheme.colorScheme + .surfaceColorAtElevation(1.dp) + .copy(alpha = .75f), + shape = RoundedCornerShape(16.dp), + ).focusProperties { + onExit = { + playButtonFocusRequester.tryRequestFocus() + } + }.focusRequester(focusRequester) + .focusGroup() + .focusRestorer(focus), + ) { + itemsIndexed(items) { index, item -> + PlaylistItem( + item = item, + index = index, + onClick = { + savedIndex = index + item?.let { + onClickIndex.invoke(index, item) + } + }, + onLongClick = { + savedIndex = index + item?.let { + onLongClickIndex.invoke(index, item) + } + }, + modifier = + Modifier + .height(80.dp) + .ifElse( + index == savedIndex, + Modifier.focusRequester(focus), + ).onFocusChanged { + if (it.isFocused) { + focusedIndex = index + } + }.focusProperties { + left = playButtonFocusRequester + previous = playButtonFocusRequester + }, + ) + } + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = + Modifier + .focusProperties { + onExit = { + playButtonFocusRequester.tryRequestFocus() + } + }.focusRequester(focusRequester) + .focusable(), + ) + } + } } } } @@ -338,6 +500,10 @@ fun PlaylistDetailsHeader( focusedItem: BaseItem?, onClickPlay: (shuffle: Boolean) -> Unit, playButtonFocusRequester: FocusRequester, + focusRequester: FocusRequester, + filterAndSort: FilterAndSort, + onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, modifier: Modifier = Modifier, ) { Column( @@ -346,13 +512,14 @@ fun PlaylistDetailsHeader( ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.focusRequester(playButtonFocusRequester), + modifier = Modifier, ) { ExpandablePlayButton( title = R.string.play, resume = Duration.ZERO, icon = Icons.Default.PlayArrow, onClick = { onClickPlay.invoke(false) }, + modifier = Modifier.focusRequester(playButtonFocusRequester), ) ExpandableFaButton( title = R.string.shuffle, @@ -360,16 +527,45 @@ fun PlaylistDetailsHeader( onClick = { onClickPlay.invoke(true) }, ) } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier, + ) { + FilterByButton( + filterOptions = DefaultPlaylistItemsOptions, + current = filterAndSort.filter, + onFilterChange = { + onFilterAndSortChange.invoke( + it, + filterAndSort.sortAndDirection, + ) + }, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier.focusRequester(focusRequester), + ) + SortByButton( + sortOptions = BoxSetSortOptions, + current = filterAndSort.sortAndDirection, + onSortChange = { onFilterAndSortChange.invoke(filterAndSort.filter, it) }, + ) + } Text( text = focusedItem?.title ?: "", color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineLarge, + style = MaterialTheme.typography.headlineSmall, ) Text( text = focusedItem?.subtitle ?: "", color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleMedium, ) + if (focusedItem?.type == BaseItemKind.EPISODE && focusedItem.data.premiereDate != null) { + Text( + text = formatDateTime(focusedItem.data.premiereDate!!), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall, + ) + } OverviewText( overview = focusedItem?.data?.overview ?: "", maxLines = 10, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt new file mode 100644 index 00000000..5358a14e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt @@ -0,0 +1,113 @@ +package com.github.damontecres.wholphin.ui.util + +import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter +import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.FavoriteFilter +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.FilterVideoType +import com.github.damontecres.wholphin.data.filter.GenreFilter +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter +import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.VideoTypeFilter +import com.github.damontecres.wholphin.data.filter.YearFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.genresApi +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.api.client.extensions.yearsApi +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import timber.log.Timber +import java.util.TreeSet +import java.util.UUID + +object FilterUtils { + /** + * Gets the possible values for a filter + * + * For example, the possible genres in the parent ID + */ + suspend fun getFilterOptionValues( + api: ApiClient, + userId: UUID?, + parentId: UUID?, + filterOption: ItemFilterBy<*>, + ): List = + withContext(Dispatchers.IO) { + try { + when (filterOption) { + GenreFilter -> { + api.genresApi + .getGenres( + parentId = parentId, + userId = userId, + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + + FavoriteFilter, + PlayedFilter, + -> { + listOf( + FilterValueOption("True", null), + FilterValueOption("False", null), + ) + } + + OfficialRatingFilter -> { + api.localizationApi.getParentalRatings().content.map { + FilterValueOption(it.name ?: "", it.value) + } + } + + VideoTypeFilter -> { + FilterVideoType.entries.map { + FilterValueOption(it.readable, it) + } + } + + YearFilter -> { + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNull { + it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } + } + } + + DecadeFilter -> { + val items = TreeSet() + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNullTo(items) { + it.name + ?.toIntOrNull() + ?.div(10) + ?.times(10) + } + items.toList().sorted().map { FilterValueOption("$it's", it) } + } + + CommunityRatingFilter -> { + (1..10).map { + FilterValueOption("$it", it) + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Exception get filter value options for $filterOption") + listOf() + } + } +} From 733213d6a21280fc658f946e19718e9db954d76d Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:30:24 -0500 Subject: [PATCH 6/7] Simplify how display mode is changed (#823) ## Description Simplifies the refresh rate/display mode change code by removing the live data go between. Also, instead of a `CountDownLatch` that blocks the thread, use a `Deferred` to suspend the coroutine instead. ### Related issues Might help with #769 ### Testing Tested on nvidia shield --- .../damontecres/wholphin/MainActivity.kt | 25 ++++++++---- .../wholphin/services/RefreshRateService.kt | 39 ++++++++++--------- 2 files changed, 37 insertions(+), 27 deletions(-) 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 0f8b7ee4..af88392e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -66,9 +66,12 @@ import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree +import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -126,19 +129,12 @@ class MainActivity : AppCompatActivity() { @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + instance = this Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) if (savedInstanceState == null) { appUpgradeHandler.copySubfont(false) } - refreshRateService.refreshRateMode.observe(this) { modeId -> - // Listen for refresh rate changes - val attrs = window.attributes - if (attrs.preferredDisplayModeId != modeId) { - Timber.d("Switch preferredDisplayModeId to %s", modeId) - window.attributes = attrs.apply { preferredDisplayModeId = modeId } - } - } viewModel.serverRepository.currentUser.observe(this) { user -> if (user?.hasPin == true) { window?.setFlags( @@ -384,6 +380,16 @@ class MainActivity : AppCompatActivity() { } } + fun changeDisplayMode(modeId: Int) { + lifecycleScope.launch(Dispatchers.Main + ExceptionHandler()) { + val attrs = window.attributes + if (attrs.preferredDisplayModeId != modeId) { + Timber.d("Switch preferredDisplayModeId to %s", modeId) + window.attributes = attrs.apply { preferredDisplayModeId = modeId } + } + } + } + companion object { const val INTENT_ITEM_ID = "itemId" const val INTENT_ITEM_TYPE = "itemType" @@ -391,6 +397,9 @@ class MainActivity : AppCompatActivity() { const val INTENT_EPISODE_NUMBER = "epNum" const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_ID = "seaId" + + lateinit var instance: MainActivity + private set } } 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 be6e461e..d961d4c5 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 @@ -6,17 +6,17 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.view.Display -import androidx.lifecycle.LiveData -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import timber.log.Timber -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton import kotlin.math.roundToInt @@ -30,7 +30,6 @@ class RefreshRateService ) { private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) - private val originalMode = display.mode val supportedDisplayModes get() = display.supportedModes.orEmpty() @@ -44,9 +43,6 @@ class RefreshRateService ) } - 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 */ @@ -54,10 +50,10 @@ class RefreshRateService stream: MediaStream, switchRefreshRate: Boolean, switchResolution: Boolean, - ) { + ) = withContext(Dispatchers.IO) { if (!switchRefreshRate && !switchResolution) { Timber.v("Not switching either refresh rate nor resolution") - return + return@withContext } val currentDisplayMode = display.mode require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } @@ -67,7 +63,7 @@ class RefreshRateService 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 + return@withContext } Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate) val targetMode = @@ -86,14 +82,20 @@ class RefreshRateService listener, Handler(Looper.myLooper() ?: Looper.getMainLooper()), ) - _refreshRateMode.setValueOnMain(targetMode.modeId) try { - if (!listener.latch.await(5, TimeUnit.SECONDS)) { + MainActivity.instance.changeDisplayMode(targetMode.modeId) + val result = + withTimeoutOrNull(5.seconds) { + listener.deferred.await() + } + if (result == null) { Timber.w("Timed out waiting for display change") showToast(context, "Refresh rate switch is taking a long time") } - } catch (ex: InterruptedException) { + } catch (ex: Exception) { Timber.w(ex, "Exception waiting for refresh rate switch") + } finally { + displayManager.unregisterDisplayListener(listener) } val targetRate = (targetMode.refreshRate * 1000).roundToInt() val isSeamless = @@ -110,7 +112,6 @@ class RefreshRateService // Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate) delay(2.seconds) } - displayManager.unregisterDisplayListener(listener) } } @@ -118,13 +119,13 @@ class RefreshRateService * Reset the display mode to the original */ fun resetRefreshRate() { - _refreshRateMode.value = originalMode.modeId + MainActivity.instance.changeDisplayMode(0) } private class Listener( val displayId: Int, ) : DisplayManager.DisplayListener { - val latch = CountDownLatch(1) + val deferred = CompletableDeferred() override fun onDisplayAdded(displayId: Int) { } @@ -132,7 +133,7 @@ class RefreshRateService override fun onDisplayChanged(displayId: Int) { if (displayId == this.displayId) { Timber.v("Got display change for $displayId") - latch.countDown() + deferred.complete(Unit) } } From b0b8ae3639979f87758530fd4b8e49f8cee14822 Mon Sep 17 00:00:00 2001 From: YogiBear12 <139140546+YogiBear12@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:55:05 +1100 Subject: [PATCH 7/7] Align typography and placement between Home, MovieDetails and SeriesDetails (#751) ## Description This PR addresses some quick changes to align the styling and placement of elements on the Home, Movie details and Series details pages, using Home as the reference point for the typography. ### Related issues Ongoing discussion [here](https://github.com/damontecres/Wholphin/discussions/704) ### Screenshots Movie Details Screenshot_20260123_223116 Series Details Screenshot_20260123_223207 ### AI/LLM usage LLM was only used to identify the current styling applied to the pages. All code changes were made and reviewed manually --- .../wholphin/ui/cards/ChapterRow.kt | 2 +- .../damontecres/wholphin/ui/cards/ItemRow.kt | 2 ++ .../wholphin/ui/cards/PersonRow.kt | 2 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 4 ++-- .../ui/detail/movie/MovieDetailsHeader.kt | 24 ++++++++++--------- .../ui/detail/series/SeriesDetails.kt | 23 +++++++++++------- .../damontecres/wholphin/ui/main/HomePage.kt | 2 +- 7 files changed, 34 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt index cf799c54..b1e64a2a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt @@ -34,6 +34,7 @@ fun ChapterRow( text = stringResource(R.string.chapters), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = rememberLazyListState(), @@ -41,7 +42,6 @@ fun ChapterRow( contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp), modifier = Modifier - .padding(start = 16.dp) .fillMaxWidth() .focusRestorer(), ) { 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 83166fa7..314a7e1a 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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -57,6 +58,7 @@ fun ItemRow( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = state, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt index 2895db83..140cc2fd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt @@ -46,6 +46,7 @@ fun PersonRow( text = stringResource(title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = rememberLazyListState(), @@ -53,7 +54,6 @@ fun PersonRow( contentPadding = PaddingValues(8.dp), modifier = Modifier - .padding(start = 16.dp) .fillMaxWidth() .focusRestorer(firstFocus), ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 58e7762d..b4c4584d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -410,7 +410,7 @@ fun MovieDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { @@ -430,7 +430,7 @@ fun MovieDetailsContent( modifier = Modifier .fillMaxWidth() - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = 40.dp, bottom = 16.dp), ) ExpandablePlayButtons( resumePosition = resumePosition, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 60451804..4d5f72d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -46,46 +46,47 @@ fun MovieDetailsHeader( val context = LocalContext.current val scope = rememberCoroutineScope() Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { // Title Text( text = movie.name ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.SemiBold, - maxLines = 2, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - val padding = 4.dp QuickDetails( movie.ui.quickDetails, movie.timeRemainingOrRuntime, - Modifier.padding(bottom = padding), + Modifier.padding(start = 8.dp), ) dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(bottom = padding)) + GenreText(it, Modifier.padding(start = 8.dp)) } VideoStreamDetails( chosenStreams = chosenStreams, numberOfVersions = movie.data.mediaSourceCount ?: 0, - modifier = Modifier.padding(bottom = padding), + modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 16.dp), ) dto.taglines?.firstOrNull()?.let { tagline -> Text( text = tagline, style = MaterialTheme.typography.bodyLarge, fontStyle = FontStyle.Italic, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) } @@ -120,6 +121,7 @@ fun MovieDetailsHeader( text = stringResource(R.string.directed_by, it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 8.dp), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 305c6451..1db5bb61 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -332,12 +333,12 @@ fun SeriesDetailsContent( Column( modifier = Modifier - .padding(16.dp) + .padding(horizontal = 24.dp, vertical = 16.dp) .fillMaxSize(), ) { LazyColumn( contentPadding = PaddingValues(bottom = 80.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier, ) { item { @@ -354,7 +355,7 @@ fun SeriesDetailsContent( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(start = 16.dp) + .padding(start = 8.dp) .focusRequester(focusRequesters[HEADER_ROW]) .focusRestorer(playFocusRequester) .focusGroup() @@ -599,23 +600,27 @@ fun SeriesDetailsHeader( val scope = rememberCoroutineScope() val dto = series.data Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { Text( text = series.name ?: stringResource(R.string.unknown), - style = MaterialTheme.typography.displaySmall, - maxLines = 2, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - QuickDetails(series.ui.quickDetails, null) + QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp)) dto.genres?.letNotEmpty { - GenreText(it) + GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp)) } dto.overview?.let { overview -> OverviewText( 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 4c58b00c..0e6e172b 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 @@ -244,7 +244,7 @@ fun HomePageContent( verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues( - start = 16.dp, + start = 24.dp, end = 16.dp, top = 0.dp, bottom = Cards.height2x3,