From 616ffe8de334fe2a8096dfea055da6d3a92a10b0 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 9 Oct 2025 13:01:57 -0400 Subject: [PATCH] UI updates to movie & search pages --- README.md | 8 +- .../dolphin/data/ServerRepository.kt | 2 + .../damontecres/dolphin/ui/Extensions.kt | 12 ++ .../dolphin/ui/cards/EpisodeCard.kt | 143 ++++++++++++++++++ .../damontecres/dolphin/ui/cards/ItemCard.kt | 8 +- .../damontecres/dolphin/ui/cards/ItemRow.kt | 4 +- .../dolphin/ui/cards/PersonCard.kt | 45 +++--- .../damontecres/dolphin/ui/cards/PersonRow.kt | 6 +- .../dolphin/ui/detail/movie/MovieDetails.kt | 3 +- .../ui/detail/movie/MovieDetailsHeader.kt | 14 +- .../damontecres/dolphin/ui/main/SearchPage.kt | 31 +++- 11 files changed, 235 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/cards/EpisodeCard.kt diff --git a/README.md b/README.md index 10d2f863..4b4dbb55 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Dolphin - an OSS Android TV client for Jellyfin -This is an Android TV client for [Jellyfin](https://jellyfin.org/). It aims to provide a more "Plex app"-like app experience for users migrating from Plex to Jellyfin. +This is an Android TV client for [Jellyfin](https://jellyfin.org/). It aims to provide a more Plex inspired UI/UX experience for users migrating from Plex to Jellyfin. This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). The user interface and controls have been written completely from scratch. @@ -15,7 +15,7 @@ That said, Dolphin does not yet implement every feature in Jellyfin. It is a wor - A navigation drawer for quick access to libraries, search, and settings from almost anywhere in the app - Show Movie/TV Show titles when browsing libraries - Play TV Show theme music, if available -- "Plex app"-like playback controls, plus other enhancements, such as: +- Plex inspired playback controls, plus other enhancements, such as: - Using D-Pad left/right for seeking during playback - Subtly show playback position while seeking w/ D-Pad - Quickly access video chapters during playback @@ -29,8 +29,8 @@ That said, Dolphin does not yet implement every feature in Jellyfin. It is a wor - https://developer.android.com/distribute/marketing-tools/alternative-distribution#unknown-sources - https://www.aftvnews.com/how-to-enable-apps-from-unknown-sources-on-an-amazon-fire-tv-or-fire-tv-stick/ 1. Install the APK on your Android TV device with one of these options: - - Install a browser program such as [Downloader](https://www.aftvnews.com/downloader/), use it to get the latest apk with short code `TODO ENTER A VALUE HERE` or URL: https://aftv.news/TODOTODO - - Download the latest APK release from the [releases page](https://github.com/damontecres/StashAppAndroidTV/releases/latest) or https://aftv.news/745800 + - Install a browser program such as [Downloader](https://www.aftvnews.com/downloader/), use it to get the latest apk with short code `Dolphin ENTER A VALUE HERE` or URL: https://aftv.news/ Dolphin + - Download the latest APK release from the [releases page](https://github.com/damontecres/Dolphin/releases/latest) or https://aftv.news/ Dolphin - Put the APK on an SD Card/USB stick/network share and use a file manager app from the Google Play Store / Amazon AppStore (e.g. `FX File Explorer`). Android's preinstalled file manager probably will not work! - Use `Send files to TV` from the Google Play Store on your phone & TV - (Expert) Use [ADB](https://developer.android.com/studio/command-line/adb) to install the APK from your computer ([guide](https://fossbytes.com/side-load-apps-android-tv/#h-how-to-sideload-apps-on-your-android-tv-using-adb)) diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt index 6f77521f..efc765b6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt @@ -7,6 +7,7 @@ import androidx.datastore.core.DataStore import com.github.damontecres.dolphin.preferences.AppPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.userApi @@ -20,6 +21,7 @@ import javax.inject.Singleton class ServerRepository @Inject constructor( + val jellyfin: Jellyfin, val serverDao: JellyfinServerDao, val apiClient: ApiClient, val userPreferencesDataStore: DataStore, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt index 6763c1d2..f4dcb046 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.media3.common.Player +import coil3.request.ErrorResult import kotlinx.coroutines.CoroutineScope import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.extensions.ticks @@ -253,3 +254,14 @@ fun Activity.keepScreenOn(keep: Boolean) { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } + +fun logCoilError( + url: String?, + errorResult: ErrorResult, +) { + if (errorResult.throwable is coil3.network.HttpException) { + Timber.w("Error loading image: %s for %s", errorResult.throwable.localizedMessage, url) + } else { + Timber.e(errorResult.throwable, "Error loading image: %s", url) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/EpisodeCard.kt new file mode 100644 index 00000000..b8dab51a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/EpisodeCard.kt @@ -0,0 +1,143 @@ +package com.github.damontecres.dolphin.ui.cards + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.data.model.BaseItem +import com.github.damontecres.dolphin.ui.AppColors +import com.github.damontecres.dolphin.ui.enableMarquee +import com.github.damontecres.dolphin.util.seasonEpisode +import kotlinx.coroutines.delay + +@Composable +fun EpisodeCard( + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + imageHeight: Dp = Dp.Unspecified, + imageWidth: Dp = Dp.Unspecified, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val dto = item?.data + val focused by interactionSource.collectIsFocusedAsState() + val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) + val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + var focusedAfterDelay by remember { mutableStateOf(false) } + + val hideOverlayDelay = 500L + if (focused) { + LaunchedEffect(Unit) { + delay(hideOverlayDelay) + if (focused) { + focusedAfterDelay = true + } else { + focusedAfterDelay = false + } + } + } else { + focusedAfterDelay = false + } + val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f) + val width = imageHeight * aspectRatio + val height = imageWidth * (1f / aspectRatio) + Column( + verticalArrangement = Arrangement.spacedBy(spaceBetween), + modifier = modifier.size(width, height), + ) { + Card( + modifier = + Modifier + .size(imageWidth, imageHeight) + .aspectRatio(aspectRatio), + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = Color.Transparent, + ), + ) { + Box( + modifier = + Modifier + .fillMaxSize(), + ) { + ItemCardImage( + imageUrl = item?.imageUrl, + name = item?.name, + showOverlay = false, + favorite = dto?.userData?.isFavorite ?: false, + watched = dto?.userData?.played ?: false, + unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, + watchedPercent = dto?.userData?.playedPercentage, + useFallbackText = false, + modifier = + Modifier + .fillMaxSize(), + ) + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .background(AppColors.TransparentBlack50, shape = RoundedCornerShape(8.dp)), + ) { + Text( + text = dto?.seasonEpisode ?: "", + modifier = Modifier.padding(4.dp), + ) + } + } + } + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.padding(bottom = spaceBelow).fillMaxWidth(), + ) { + Text( + text = dto?.seriesName ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + Text( + text = item?.name ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt index 3b99f62b..712ea7b6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt @@ -52,10 +52,10 @@ import com.github.damontecres.dolphin.ui.FontAwesome import com.github.damontecres.dolphin.ui.enableMarquee import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.isNotNullOrBlank +import com.github.damontecres.dolphin.ui.logCoilError import com.github.damontecres.dolphin.util.seasonEpisode import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.BaseItemKind -import timber.log.Timber @Composable fun ItemCard( @@ -176,11 +176,8 @@ fun ItemCardImage( contentDescription = name, contentScale = ContentScale.Fit, alignment = Alignment.Center, - // TODO error/fallback images - error = null, - fallback = null, onError = { - Timber.e(it.result.throwable, "Error loading image: $imageUrl") + logCoilError(imageUrl, it.result) imageError = true }, modifier = @@ -189,6 +186,7 @@ fun ItemCardImage( .align(Alignment.TopCenter), ) } else { + // TODO options for overriding fallback Box( modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemRow.kt index 3511dc35..9fa2763c 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemRow.kt @@ -4,7 +4,6 @@ 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 @@ -63,10 +62,9 @@ fun ItemRow( LazyRow( state = state, horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier - .padding(start = 16.dp) .fillMaxWidth() .focusRestorer(focusPair?.focusRequester ?: firstFocus), ) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonCard.kt index 84795f5e..84d18bdc 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonCard.kt @@ -1,13 +1,13 @@ package com.github.damontecres.dolphin.ui.cards +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -17,7 +17,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -32,8 +31,6 @@ fun PersonCard( onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, - cardWidth: Dp = 150.dp * .75f, - cardHeight: Dp = 200.dp * .75f, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val hideOverlayDelay = 1_000L @@ -53,34 +50,44 @@ fun PersonCard( } else { focusedAfterDelay = false } - - Card( + val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) + val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + Column( + verticalArrangement = Arrangement.spacedBy(spaceBetween), modifier = modifier, - onClick = onClick, - onLongClick = onLongClick, - interactionSource = interactionSource, - colors = - CardDefaults.colors( - containerColor = Color.Transparent, - ), ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.width(cardWidth), + Card( + modifier = Modifier, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = Color.Transparent, + ), ) { ItemCardImage( imageUrl = item.imageUrl, name = item.name, - showOverlay = true, + showOverlay = false, favorite = false, watched = false, unwatchedCount = -1, watchedPercent = null, + useFallbackText = false, modifier = Modifier .fillMaxWidth() - .height(cardHeight), + .aspectRatio(2f / 3f), // TODO, ) + } + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .padding(bottom = spaceBelow) + .fillMaxWidth(), + ) { Text( text = item.name ?: "", maxLines = 1, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonRow.kt index 967a3026..4fede387 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonRow.kt @@ -5,6 +5,7 @@ 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.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -52,7 +53,10 @@ fun PersonRow( item = item, onClick = { onClick.invoke(item) }, onLongClick = { onLongClick?.invoke(item) }, - modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), + modifier = + Modifier + .width(120.dp) + .ifElse(index == 0, Modifier.focusRequester(firstFocus)), ) } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt index f0caaf72..981d833a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt @@ -289,7 +289,8 @@ fun MovieDetailsContent( bringIntoViewRequester = bringIntoViewRequester, overviewOnClick = overviewOnClick, Modifier - .fillMaxWidth(.7f), + .fillMaxWidth(.7f) + .padding(bottom = 8.dp), ) ExpandablePlayButtons( resumePosition = resumePosition, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt index e4d53218..ee0300e9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt @@ -57,6 +57,7 @@ fun MovieDetailsHeader( val context = LocalContext.current val scope = rememberCoroutineScope() Column( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { // Title @@ -64,7 +65,7 @@ fun MovieDetailsHeader( text = movie.name ?: "", color = MaterialTheme.colorScheme.onSurface, style = - MaterialTheme.typography.displayMedium.copy( + MaterialTheme.typography.displayLarge.copy( shadow = Shadow( color = Color.DarkGray, @@ -93,12 +94,13 @@ fun MovieDetailsHeader( ?.let { add(it) } - dto.timeRemaining?.roundMinutes?.let { add("$it left") } dto.officialRating?.let(::add) + dto.timeRemaining?.roundMinutes?.let { add("$it left") } } DotSeparatedRow( texts = details, - textStyle = MaterialTheme.typography.titleLarge, + textStyle = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 8.dp), ) } dto.communityRating?.let { @@ -109,10 +111,10 @@ fun MovieDetailsHeader( enabled = false, precision = StarRatingPrecision.HALF, playSoundOnFocus = true, - modifier = Modifier.height(40.dp), + modifier = Modifier.height(32.dp), ) } else { - Spacer(Modifier.height(40.dp)) + Spacer(Modifier.height(32.dp)) } } @@ -157,7 +159,7 @@ fun MovieDetailsHeader( Row( modifier = Modifier - .padding(top = 8.dp, start = 16.dp) + .padding(start = 16.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt index 4764b4ef..d4d05182 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt @@ -25,6 +25,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.DefaultItemFields +import com.github.damontecres.dolphin.ui.cards.EpisodeCard import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.components.EditTextBox import com.github.damontecres.dolphin.ui.isNotNullOrBlank @@ -65,6 +67,7 @@ class SearchViewModel searchTerm = query, recursive = true, includeItemTypes = listOf(BaseItemKind.MOVIE), + fields = DefaultItemFields, limit = 25, ) val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) @@ -79,6 +82,7 @@ class SearchViewModel searchTerm = query, recursive = true, includeItemTypes = listOf(BaseItemKind.SERIES), + fields = DefaultItemFields, limit = 25, ) val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) @@ -93,6 +97,7 @@ class SearchViewModel searchTerm = query, recursive = true, includeItemTypes = listOf(BaseItemKind.EPISODE), + fields = DefaultItemFields, limit = 25, ) val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) @@ -134,7 +139,7 @@ fun SearchPage( } LazyColumn( - contentPadding = PaddingValues(16.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp), verticalArrangement = Arrangement.spacedBy(16.dp), modifier = modifier, ) { @@ -158,7 +163,7 @@ fun SearchPage( ) } } - itemsIndexed(listOf(movies, series, episodes)) { index, items -> + itemsIndexed(listOf(movies, series)) { index, items -> if (items.isNotEmpty()) { ItemRow( title = @@ -177,5 +182,27 @@ fun SearchPage( ) } } + if (episodes.isNotEmpty()) { + item { + ItemRow( + title = "Episodes", + items = episodes, + onClickItem = { + viewModel.navigationManager.navigateTo(it.destination()) + }, + onLongClickItem = {}, + modifier = Modifier, + cardContent = @Composable { index, item, mod, onClick, onLongClick -> + EpisodeCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = 160.dp, + modifier = mod, + ) + }, + ) + } + } } }