UI updates to movie & search pages

This commit is contained in:
Damontecres 2025-10-09 13:01:57 -04:00
parent fe1d63793d
commit 616ffe8de3
No known key found for this signature in database
11 changed files with 235 additions and 41 deletions

View file

@ -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))

View file

@ -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<AppPreferences>,

View file

@ -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)
}
}

View file

@ -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),
)
}
}
}

View file

@ -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

View file

@ -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),
) {

View file

@ -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,9 +50,14 @@ 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,
) {
Card(
modifier = Modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
@ -63,24 +65,29 @@ fun PersonCard(
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.width(cardWidth),
) {
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,

View file

@ -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)),
)
}
}

View file

@ -289,7 +289,8 @@ fun MovieDetailsContent(
bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick,
Modifier
.fillMaxWidth(.7f),
.fillMaxWidth(.7f)
.padding(bottom = 8.dp),
)
ExpandablePlayButtons(
resumePosition = resumePosition,

View file

@ -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),
) {

View file

@ -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,
)
},
)
}
}
}
}