Lots of UI changes

This commit is contained in:
Damontecres 2025-09-23 17:58:00 -04:00
parent 6e185b25b7
commit 379559c076
No known key found for this signature in database
16 changed files with 557 additions and 71 deletions

View file

@ -1,6 +1,8 @@
package com.github.damontecres.dolphin package com.github.damontecres.dolphin
import android.view.KeyEvent import android.view.KeyEvent
import androidx.compose.foundation.MarqueeAnimationMode
import androidx.compose.foundation.basicMarquee
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -10,6 +12,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import timber.log.Timber import timber.log.Timber
import kotlin.contracts.ExperimentalContracts import kotlin.contracts.ExperimentalContracts
@ -108,3 +111,14 @@ fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) {
} }
} }
} }
fun Modifier.enableMarquee(focused: Boolean) =
if (focused) {
basicMarquee(
initialDelayMillis = 250,
animationMode = MarqueeAnimationMode.Immediately,
velocity = 40.dp,
)
} else {
basicMarquee(animationMode = MarqueeAnimationMode.WhileFocused)
}

View file

@ -0,0 +1,30 @@
package com.github.damontecres.dolphin.data.model
import com.github.damontecres.dolphin.ui.nav.Destination
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
@Serializable
data class BaseItem(
val data: BaseItemDto,
val imageUrl: String?,
) {
@Transient val id = data.id
@Transient val type = data.type
@Transient val name = data.name
fun destination() = Destination.MediaItem(id, type, this)
companion object {
fun from(
dto: BaseItemDto,
api: ApiClient,
) = BaseItem(dto, api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY))
}
}

View file

@ -4,6 +4,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.R
import org.jellyfin.sdk.model.api.ItemFields
val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900)) val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900))
@ -14,3 +15,12 @@ sealed class AppColors private constructor() {
val TransparentBlack75 = Color(0xBF000000) val TransparentBlack75 = Color(0xBF000000)
} }
} }
const val DEFAULT_PAGE_SIZE = 50
val DefaultItemFields =
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT,
)

View file

@ -0,0 +1,49 @@
package com.github.damontecres.dolphin.ui.cards
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.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
@Composable
fun CardRow(
title: String,
items: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = title,
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth(),
) {
items(items) { item ->
ItemCard(
item = item,
onClick = { if (item != null) onClickItem.invoke(item) },
onLongClick = { if (item != null) onLongClickItem.invoke(item) },
modifier = Modifier,
)
}
}
}
}

View file

@ -1,55 +1,209 @@
package com.github.damontecres.dolphin.ui.cards package com.github.damontecres.dolphin.ui.cards
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import org.jellyfin.sdk.model.api.BaseItemDto import com.github.damontecres.dolphin.R
import java.util.UUID import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.enableMarquee
import com.github.damontecres.dolphin.ui.FontAwesome
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable @Composable
fun ItemCard( fun ItemCard(
item: BaseItemDto?, item: BaseItem?,
imageUrlBuilder: (UUID) -> String?,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
cardWidth: Dp = 150.dp, cardWidth: Dp = 150.dp,
cardHeight: Dp = 200.dp, cardHeight: Dp = 200.dp,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
val hideOverlayDelay = 1_000L
val focused = interactionSource.collectIsFocusedAsState().value
var focusedAfterDelay by remember { mutableStateOf(false) }
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
if (item == null) { if (item == null) {
NullCard(modifier, cardWidth, cardHeight) NullCard(modifier, cardWidth, cardHeight)
} else { } else {
val dto = item.data
// TODO better aspect ratio handling
val height =
if (dto.primaryImageAspectRatio != null && dto.primaryImageAspectRatio!! > 1) cardWidth else cardHeight
Card( Card(
modifier = modifier, modifier = modifier,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.width(cardWidth), modifier = Modifier.width(cardWidth),
) { ) {
AsyncImage( ItemCardImage(
model = imageUrlBuilder.invoke(item.id), imageUrl = item.imageUrl,
contentDescription = item.name, name = item.name,
contentScale = ContentScale.Fit, showOverlay = !focusedAfterDelay,
favorite = dto.userData?.isFavorite ?: false,
watched = dto.userData?.played ?: false,
unwatchedCount = dto.userData?.unplayedItemCount ?: -1,
watchedPercent = dto.userData?.playedPercentage,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(cardHeight), .height(height),
) )
Text( Text(
text = item.name ?: "", text = item.name ?: "",
maxLines = 1,
modifier =
Modifier
.enableMarquee(focusedAfterDelay),
)
if (dto.type == BaseItemKind.EPISODE) {
if (dto.parentIndexNumber != null && dto.indexNumber != null) {
Text(
text = "S${dto.parentIndexNumber} E${dto.indexNumber}",
) )
Text(text = item.primaryImageAspectRatio.toString()) }
}
}
}
}
}
@Composable
fun ItemCardImage(
imageUrl: String?,
name: String?,
showOverlay: Boolean,
favorite: Boolean,
watched: Boolean,
unwatchedCount: Int,
watchedPercent: Double?,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
AsyncImage(
model = imageUrl,
contentDescription = name,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
AnimatedVisibility(
visible = showOverlay,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(modifier = Modifier.fillMaxSize()) {
if (favorite) {
Text(
modifier =
Modifier
.align(Alignment.TopStart)
.padding(8.dp),
color = colorResource(android.R.color.holo_red_light),
text = stringResource(R.string.fa_heart),
fontSize = 20.sp,
fontFamily = FontAwesome,
)
}
if (watched) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.border.copy(alpha = 1f),
modifier =
Modifier
.align(Alignment.TopEnd)
.size(48.dp)
.padding(8.dp),
)
}
if (unwatchedCount > 0) {
Box(
modifier =
Modifier
.padding(8.dp)
.align(Alignment.TopEnd),
) {
Text(
modifier =
Modifier
.background(
MaterialTheme.colorScheme.border,
shape = CircleShape,
),
color = MaterialTheme.colorScheme.onSurface,
text = unwatchedCount.toString(),
fontSize = 20.sp,
)
}
}
watchedPercent?.let { percent ->
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.background(
MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape)
.height(4.dp)
.fillMaxWidth((percent / 100.0).toFloat()),
)
}
} }
} }
} }

View file

@ -41,6 +41,7 @@ import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ifElse import com.github.damontecres.dolphin.ifElse
import com.github.damontecres.dolphin.tryRequestFocus import com.github.damontecres.dolphin.tryRequestFocus
import com.github.damontecres.dolphin.ui.AppColors import com.github.damontecres.dolphin.ui.AppColors
@ -52,21 +53,16 @@ import com.github.damontecres.dolphin.ui.playback.isForwardButton
import com.github.damontecres.dolphin.ui.playback.isPlayKeyUp import com.github.damontecres.dolphin.ui.playback.isPlayKeyUp
import com.github.damontecres.dolphin.util.DolphinPager import com.github.damontecres.dolphin.util.DolphinPager
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber import timber.log.Timber
import kotlin.math.max import kotlin.math.max
private val DEBUG = false private const val DEBUG = false
@Composable @Composable
fun CardGrid( fun CardGrid(
api: ApiClient,
pager: DolphinPager, pager: DolphinPager,
itemOnClick: (BaseItemDto) -> Unit, itemOnClick: (BaseItem) -> Unit,
longClicker: (BaseItemDto) -> Unit, longClicker: (BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
requestFocus: Boolean, requestFocus: Boolean,
gridFocusRequester: FocusRequester, gridFocusRequester: FocusRequester,
@ -329,7 +325,6 @@ fun CardGrid(
item = item, item = item,
onClick = { if (item != null) itemOnClick.invoke(item) }, onClick = { if (item != null) itemOnClick.invoke(item) },
onLongClick = { if (item != null) longClicker.invoke(item) }, onLongClick = { if (item != null) longClicker.invoke(item) },
imageUrlBuilder = { api.imageApi.getItemImageUrl(it, ImageType.PRIMARY) },
) )
} }
} }

View file

@ -11,6 +11,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.OneTimeLaunchedEffect import com.github.damontecres.dolphin.OneTimeLaunchedEffect
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Library import com.github.damontecres.dolphin.data.model.Library
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
@ -20,7 +21,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
@ -41,7 +41,7 @@ class CollectionFolderViewModel
override fun init( override fun init(
itemId: UUID, itemId: UUID,
potential: BaseItemDto?, potential: BaseItem?,
): Job = ): Job =
viewModelScope.launch { viewModelScope.launch {
super.init(itemId, potential)?.join() super.init(itemId, potential)?.join()
@ -93,7 +93,14 @@ fun CollectionFolderDetails(
CollectionType.UNKNOWN -> TODO() CollectionType.UNKNOWN -> TODO()
CollectionType.MOVIES -> TODO() CollectionType.MOVIES -> TODO()
CollectionType.TVSHOWS -> { CollectionType.TVSHOWS -> {
TVShowCollectionDetails(viewModel.api, preferences, navigationManager, library!!, item!!, pager, modifier) TVShowCollectionDetails(
preferences,
navigationManager,
library!!,
item!!,
pager,
modifier,
)
} }
CollectionType.MUSIC -> TODO() CollectionType.MUSIC -> TODO()
@ -113,17 +120,15 @@ fun CollectionFolderDetails(
@Composable @Composable
fun TVShowCollectionDetails( fun TVShowCollectionDetails(
api: ApiClient,
preferences: UserPreferences, preferences: UserPreferences,
navigationManager: NavigationManager, navigationManager: NavigationManager,
library: Library, library: Library,
item: BaseItemDto, item: BaseItem,
pager: DolphinPager, pager: DolphinPager,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val gridFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() }
CardGrid( CardGrid(
api = api,
pager = pager, pager = pager,
itemOnClick = { navigationManager.navigateTo(Destination.MediaItem(it.id, it.type, it)) }, itemOnClick = { navigationManager.navigateTo(Destination.MediaItem(it.id, it.type, it)) },
longClicker = {}, longClicker = {},

View file

@ -1,11 +1,14 @@
package com.github.damontecres.dolphin.ui.detail package com.github.damontecres.dolphin.ui.detail
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.Text import androidx.tv.material3.Text
@ -40,10 +43,27 @@ fun EpisodeDetails(
Text(text = "Loading...") Text(text = "Loading...")
} else { } else {
item?.let { item -> item?.let { item ->
LazyColumn(modifier = modifier) { val dto = item.data
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(32.dp),
modifier = modifier,
) {
item { item {
Text(text = item.name ?: "Unknown") Text(text = item.name ?: "Unknown")
} }
item {
if (dto.parentIndexNumber != null && dto.indexNumber != null) {
Text(
text = "S${dto.parentIndexNumber} E${dto.indexNumber}",
)
}
}
dto.overview?.let {
item {
Text(text = it)
}
}
item { item {
Button( Button(
onClick = { onClick = {

View file

@ -3,25 +3,25 @@ package com.github.damontecres.dolphin.ui.detail
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.DolphinModel import com.github.damontecres.dolphin.data.model.DolphinModel
import com.github.damontecres.dolphin.data.model.convertModel import com.github.damontecres.dolphin.data.model.convertModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
abstract class ItemViewModel<T : DolphinModel>( abstract class ItemViewModel<T : DolphinModel>(
val api: ApiClient, val api: ApiClient,
) : ViewModel() { ) : ViewModel() {
val item = MutableLiveData<BaseItemDto?>(null) val item = MutableLiveData<BaseItem?>(null)
val model = MutableLiveData<T?>(null) val model = MutableLiveData<T?>(null)
open fun init( open fun init(
itemId: UUID, itemId: UUID,
potential: BaseItemDto?, potential: BaseItem?,
): Job? { ): Job? {
if (item.value == null && potential?.id == itemId) { if (item.value == null && potential?.id == itemId) {
item.value = potential item.value = potential
@ -34,7 +34,7 @@ abstract class ItemViewModel<T : DolphinModel>(
try { try {
val fetchedItem = api.userLibraryApi.getItem(itemId).content val fetchedItem = api.userLibraryApi.getItem(itemId).content
val modelInstance = convertModel(fetchedItem, api) val modelInstance = convertModel(fetchedItem, api)
item.value = fetchedItem item.value = BaseItem.from(fetchedItem, api)
model.value = modelInstance as T model.value = modelInstance as T
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to load item $itemId") Timber.e(e, "Failed to load item $itemId")

View file

@ -16,8 +16,8 @@ fun MediaItemContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (destination.type) { when (destination.type) {
BaseItemKind.SERIES -> TODO() BaseItemKind.SERIES -> SeriesDetails(preferences, navigationManager, destination, modifier)
BaseItemKind.SEASON -> TODO() BaseItemKind.SEASON -> SeasonDetails(preferences, navigationManager, destination, modifier)
BaseItemKind.EPISODE -> EpisodeDetails(preferences, navigationManager, destination, modifier) BaseItemKind.EPISODE -> EpisodeDetails(preferences, navigationManager, destination, modifier)
BaseItemKind.MOVIE -> TODO() BaseItemKind.MOVIE -> TODO()
BaseItemKind.VIDEO -> TODO() BaseItemKind.VIDEO -> TODO()

View file

@ -0,0 +1,97 @@
package com.github.damontecres.dolphin.ui.detail
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.CardRow
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.util.DolphinPager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class SeasonViewModel
@Inject
constructor(
api: ApiClient,
) : ItemViewModel<Video>(api) {
val episodes = MutableLiveData<List<BaseItem?>>(listOf())
override fun init(
itemId: UUID,
potential: BaseItem?,
): Job? =
viewModelScope.launch {
super.init(itemId, potential)?.join()
item.value?.let { item ->
val request =
GetItemsRequest(
parentId = item.id,
recursive = false,
includeItemTypes = listOf(BaseItemKind.EPISODE),
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
sortOrder = listOf(SortOrder.ASCENDING),
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.CHILD_COUNT),
)
val pager = DolphinPager(api, request, viewModelScope, itemCount = item.data.childCount)
pager.init()
episodes.value = pager
}
}
}
@Composable
fun SeasonDetails(
preferences: UserPreferences,
navigationManager: NavigationManager,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: SeasonViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
val episodes by viewModel.episodes.observeAsState(listOf())
if (item == null) {
Text(text = "Loading...")
} else {
item?.let { item ->
LazyColumn(modifier = modifier) {
item {
Text(text = item.name ?: "Unknown")
}
item {
CardRow(
title = "Episodes",
items = episodes,
onClickItem = { navigationManager.navigateTo(it.destination()) },
onLongClickItem = { },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}

View file

@ -0,0 +1,102 @@
package com.github.damontecres.dolphin.ui.detail
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.Video
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.CardRow
import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.util.DolphinPager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class SeriesViewModel
@Inject
constructor(
api: ApiClient,
) : ItemViewModel<Video>(api) {
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
override fun init(
itemId: UUID,
potential: BaseItem?,
): Job? =
viewModelScope.launch {
super.init(itemId, potential)?.join()
item.value?.let { item ->
val request =
GetItemsRequest(
parentId = item.id,
recursive = false,
includeItemTypes = listOf(BaseItemKind.SEASON),
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
sortOrder = listOf(SortOrder.ASCENDING),
fields =
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
ItemFields.SEASON_USER_DATA,
),
)
val pager = DolphinPager(api, request, viewModelScope, itemCount = item.data.childCount)
pager.init()
seasons.value = pager
}
}
}
@Composable
fun SeriesDetails(
preferences: UserPreferences,
navigationManager: NavigationManager,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf())
if (item == null) {
Text(text = "Loading...")
} else {
item?.let { item ->
LazyColumn(modifier = modifier) {
item {
Text(text = item.name ?: "Unknown")
}
item {
CardRow(
title = "Seasons",
items = seasons,
onClickItem = { navigationManager.navigateTo(it.destination()) },
onLongClickItem = { },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}

View file

@ -19,11 +19,11 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.DolphinModel import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.data.model.convertModel
import com.github.damontecres.dolphin.isNotNullOrBlank import com.github.damontecres.dolphin.isNotNullOrBlank
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.cards.DolphinCard import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.cards.ItemCard
import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.Destination
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -44,7 +44,7 @@ import javax.inject.Inject
data class HomeRow( data class HomeRow(
val section: HomeSection, val section: HomeSection,
val items: List<DolphinModel>, val items: List<BaseItem>,
) )
@HiltViewModel @HiltViewModel
@ -80,7 +80,7 @@ class MainViewModel
user.configuration?.orderedViews?.firstOrNull()?.let { viewId -> user.configuration?.orderedViews?.firstOrNull()?.let { viewId ->
val request = val request =
GetLatestMediaRequest( GetLatestMediaRequest(
fields = listOf(), fields = DefaultItemFields,
imageTypeLimit = 1, imageTypeLimit = 1,
parentId = viewId, parentId = viewId,
groupItems = true, groupItems = true,
@ -90,7 +90,7 @@ class MainViewModel
api.userLibraryApi api.userLibraryApi
.getLatestMedia(request) .getLatestMedia(request)
.content .content
.map { convertModel(it, api) } .map { BaseItem.from(it, api) }
HomeRow( HomeRow(
section = section, section = section,
items = latest, items = latest,
@ -104,6 +104,7 @@ class MainViewModel
val request = val request =
GetResumeItemsRequest( GetResumeItemsRequest(
userId = user.id, userId = user.id,
fields = DefaultItemFields,
// TODO, more params? // TODO, more params?
) )
val items = val items =
@ -111,7 +112,7 @@ class MainViewModel
.getResumeItems(request) .getResumeItems(request)
.content .content
.items .items
.map { convertModel(it, api) } .map { BaseItem.from(it, api) }
HomeRow( HomeRow(
section = section, section = section,
items = items, items = items,
@ -121,7 +122,7 @@ class MainViewModel
HomeSection.NEXT_UP -> { HomeSection.NEXT_UP -> {
val request = val request =
GetNextUpRequest( GetNextUpRequest(
fields = listOf(), fields = DefaultItemFields,
imageTypeLimit = 1, imageTypeLimit = 1,
parentId = null, parentId = null,
limit = 25, limit = 25,
@ -132,7 +133,7 @@ class MainViewModel
.getNextUp(request) .getNextUp(request)
.content .content
.items .items
.map { convertModel(it, api) } .map { BaseItem.from(it, api) }
HomeRow( HomeRow(
section = section, section = section,
items = nextUp, items = nextUp,
@ -177,6 +178,7 @@ fun MainPage(
), ),
) )
}, },
onLongClickItem = {},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
@ -188,7 +190,8 @@ fun MainPage(
@Composable @Composable
fun HomePageRow( fun HomePageRow(
row: HomeRow, row: HomeRow,
onClickItem: (DolphinModel) -> Unit, onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -207,9 +210,10 @@ fun HomePageRow(
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
items(row.items) { item -> items(row.items) { item ->
DolphinCard( ItemCard(
item = item, item = item,
onClick = { onClickItem.invoke(item) }, onClick = { onClickItem.invoke(item) },
onLongClick = { onLongClickItem.invoke(item) },
modifier = Modifier, modifier = Modifier,
) )
} }

View file

@ -1,9 +1,10 @@
package com.github.damontecres.dolphin.ui.nav package com.github.damontecres.dolphin.ui.nav
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.util.UuidSerializer import com.github.damontecres.dolphin.util.UuidSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.BaseItemDto import kotlinx.serialization.Transient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID import java.util.UUID
@ -27,13 +28,13 @@ sealed class Destination(
data class MediaItem( data class MediaItem(
@Serializable(with = UuidSerializer::class) val itemId: UUID, @Serializable(with = UuidSerializer::class) val itemId: UUID,
val type: BaseItemKind, val type: BaseItemKind,
@Transient val item: BaseItemDto? = null, @Transient val item: BaseItem? = null,
) : Destination() ) : Destination()
@Serializable @Serializable
data class Playback( data class Playback(
@Serializable(with = UuidSerializer::class) val itemId: UUID, @Serializable(with = UuidSerializer::class) val itemId: UUID,
val positionMs: Long, val positionMs: Long,
@Transient val item: BaseItemDto? = null, @Transient val item: BaseItem? = null,
) : Destination(true) ) : Destination(true)
} }

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.dolphin.data.model.BaseItem
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -15,7 +16,6 @@ import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -70,19 +70,20 @@ class PlaybackViewModel
fun init( fun init(
itemId: UUID, itemId: UUID,
item: BaseItemDto?, item: BaseItem?,
) { ) {
if (item != null) { if (item != null) {
title.value = item.name title.value = item.name
val base = item.data
if (item.type == BaseItemKind.EPISODE) { if (item.type == BaseItemKind.EPISODE) {
val season = item.parentIndexNumber?.toString()?.padStart(2, '0') val season = base.parentIndexNumber?.toString()?.padStart(2, '0')
val episode = item.indexNumber?.toString()?.padStart(2, '0') val episode = base.indexNumber?.toString()?.padStart(2, '0')
// TODO multi episode support // TODO multi episode support
if (season != null && episode != null) { if (season != null && episode != null) {
subtitle.value = subtitle.value =
buildString { buildString {
if (item.seriesName != null) { if (base.seriesName != null) {
append(item.seriesName) append(base.seriesName)
append(" - ") append(" - ")
} }
append("S${season}E$episode") append("S${season}E$episode")

View file

@ -4,6 +4,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.DEFAULT_PAGE_SIZE
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -13,7 +15,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber import timber.log.Timber
@ -21,19 +22,21 @@ class DolphinPager(
val api: ApiClient, val api: ApiClient,
val request: GetItemsRequest, val request: GetItemsRequest,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val pageSize: Int = 25, private val pageSize: Int = DEFAULT_PAGE_SIZE,
itemCount: Int? = null,
cacheSize: Long = 8, cacheSize: Long = 8,
) : AbstractList<BaseItemDto?>() { ) : AbstractList<BaseItem?>() {
private var items by mutableStateOf(ItemList<BaseItemDto>(0, pageSize, mapOf())) private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
private var totalCount by mutableIntStateOf(-1) private var totalCount by mutableIntStateOf(itemCount ?: -1)
private val mutex = Mutex() private val mutex = Mutex()
private val cachedPages = private val cachedPages =
CacheBuilder CacheBuilder
.newBuilder() .newBuilder()
.maximumSize(cacheSize) .maximumSize(cacheSize)
.build<Int, List<BaseItemDto>>() .build<Int, List<BaseItem>>()
suspend fun init() { suspend fun init() {
if (totalCount < 0) {
val result = val result =
api.itemsApi api.itemsApi
.getItems( .getItems(
@ -45,8 +48,9 @@ class DolphinPager(
).content ).content
totalCount = result.totalRecordCount totalCount = result.totalRecordCount
} }
}
override operator fun get(index: Int): BaseItemDto? { override operator fun get(index: Int): BaseItem? {
if (index in 0..<totalCount) { if (index in 0..<totalCount) {
val item = items[index] val item = items[index]
if (item == null) { if (item == null) {
@ -58,7 +62,7 @@ class DolphinPager(
} }
} }
suspend fun getBlocking(position: Int): BaseItemDto? { suspend fun getBlocking(position: Int): BaseItem? {
if (position in 0..<totalCount) { if (position in 0..<totalCount) {
val item = items[position] val item = items[position]
if (item == null) { if (item == null) {
@ -89,7 +93,7 @@ class DolphinPager(
enableTotalRecordCount = false, enableTotalRecordCount = false,
), ),
).content ).content
val data = result.items val data = result.items.map { BaseItem.from(it, api) }
cachedPages.put(pageNumber, data) cachedPages.put(pageNumber, data)
items = ItemList(totalCount, pageSize, cachedPages.asMap()) items = ItemList(totalCount, pageSize, cachedPages.asMap())
} }