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
import android.view.KeyEvent
import androidx.compose.foundation.MarqueeAnimationMode
import androidx.compose.foundation.basicMarquee
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -10,6 +12,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import timber.log.Timber
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.FontFamily
import com.github.damontecres.dolphin.R
import org.jellyfin.sdk.model.api.ItemFields
val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900))
@ -14,3 +15,12 @@ sealed class AppColors private constructor() {
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
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
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.draw.clip
import androidx.compose.ui.graphics.RectangleShape
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.sp
import androidx.tv.material3.Card
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import org.jellyfin.sdk.model.api.BaseItemDto
import java.util.UUID
import com.github.damontecres.dolphin.R
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
fun ItemCard(
item: BaseItemDto?,
imageUrlBuilder: (UUID) -> String?,
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
cardWidth: Dp = 150.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) {
NullCard(modifier, cardWidth, cardHeight)
} else {
val dto = item.data
// TODO better aspect ratio handling
val height =
if (dto.primaryImageAspectRatio != null && dto.primaryImageAspectRatio!! > 1) cardWidth else cardHeight
Card(
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.width(cardWidth),
) {
AsyncImage(
model = imageUrlBuilder.invoke(item.id),
contentDescription = item.name,
contentScale = ContentScale.Fit,
ItemCardImage(
imageUrl = item.imageUrl,
name = item.name,
showOverlay = !focusedAfterDelay,
favorite = dto.userData?.isFavorite ?: false,
watched = dto.userData?.played ?: false,
unwatchedCount = dto.userData?.unplayedItemCount ?: -1,
watchedPercent = dto.userData?.playedPercentage,
modifier =
Modifier
.fillMaxWidth()
.height(cardHeight),
.height(height),
)
Text(
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.Text
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.tryRequestFocus
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.util.DolphinPager
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 kotlin.math.max
private val DEBUG = false
private const val DEBUG = false
@Composable
fun CardGrid(
api: ApiClient,
pager: DolphinPager,
itemOnClick: (BaseItemDto) -> Unit,
longClicker: (BaseItemDto) -> Unit,
itemOnClick: (BaseItem) -> Unit,
longClicker: (BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int,
requestFocus: Boolean,
gridFocusRequester: FocusRequester,
@ -329,7 +325,6 @@ fun CardGrid(
item = item,
onClick = { if (item != null) itemOnClick.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.tv.material3.Text
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.preferences.UserPreferences
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.launch
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.CollectionType
import org.jellyfin.sdk.model.api.ImageType
@ -41,7 +41,7 @@ class CollectionFolderViewModel
override fun init(
itemId: UUID,
potential: BaseItemDto?,
potential: BaseItem?,
): Job =
viewModelScope.launch {
super.init(itemId, potential)?.join()
@ -93,7 +93,14 @@ fun CollectionFolderDetails(
CollectionType.UNKNOWN -> TODO()
CollectionType.MOVIES -> TODO()
CollectionType.TVSHOWS -> {
TVShowCollectionDetails(viewModel.api, preferences, navigationManager, library!!, item!!, pager, modifier)
TVShowCollectionDetails(
preferences,
navigationManager,
library!!,
item!!,
pager,
modifier,
)
}
CollectionType.MUSIC -> TODO()
@ -113,17 +120,15 @@ fun CollectionFolderDetails(
@Composable
fun TVShowCollectionDetails(
api: ApiClient,
preferences: UserPreferences,
navigationManager: NavigationManager,
library: Library,
item: BaseItemDto,
item: BaseItem,
pager: DolphinPager,
modifier: Modifier = Modifier,
) {
val gridFocusRequester = remember { FocusRequester() }
CardGrid(
api = api,
pager = pager,
itemOnClick = { navigationManager.navigateTo(Destination.MediaItem(it.id, it.type, it)) },
longClicker = {},

View file

@ -1,11 +1,14 @@
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.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.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.Text
@ -40,10 +43,27 @@ fun EpisodeDetails(
Text(text = "Loading...")
} else {
item?.let { item ->
LazyColumn(modifier = modifier) {
val dto = item.data
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(32.dp),
modifier = modifier,
) {
item {
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 {
Button(
onClick = {

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.dolphin.data.model.BaseItem
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
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.extensions.mediaInfoApi
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.PlaybackInfoDto
import org.jellyfin.sdk.model.extensions.ticks
@ -70,19 +70,20 @@ class PlaybackViewModel
fun init(
itemId: UUID,
item: BaseItemDto?,
item: BaseItem?,
) {
if (item != null) {
title.value = item.name
val base = item.data
if (item.type == BaseItemKind.EPISODE) {
val season = item.parentIndexNumber?.toString()?.padStart(2, '0')
val episode = item.indexNumber?.toString()?.padStart(2, '0')
val season = base.parentIndexNumber?.toString()?.padStart(2, '0')
val episode = base.indexNumber?.toString()?.padStart(2, '0')
// TODO multi episode support
if (season != null && episode != null) {
subtitle.value =
buildString {
if (item.seriesName != null) {
append(item.seriesName)
if (base.seriesName != null) {
append(base.seriesName)
append(" - ")
}
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.mutableStateOf
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -13,7 +15,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
@ -21,19 +22,21 @@ class DolphinPager(
val api: ApiClient,
val request: GetItemsRequest,
private val scope: CoroutineScope,
private val pageSize: Int = 25,
private val pageSize: Int = DEFAULT_PAGE_SIZE,
itemCount: Int? = null,
cacheSize: Long = 8,
) : AbstractList<BaseItemDto?>() {
private var items by mutableStateOf(ItemList<BaseItemDto>(0, pageSize, mapOf()))
private var totalCount by mutableIntStateOf(-1)
) : AbstractList<BaseItem?>() {
private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
private var totalCount by mutableIntStateOf(itemCount ?: -1)
private val mutex = Mutex()
private val cachedPages =
CacheBuilder
.newBuilder()
.maximumSize(cacheSize)
.build<Int, List<BaseItemDto>>()
.build<Int, List<BaseItem>>()
suspend fun init() {
if (totalCount < 0) {
val result =
api.itemsApi
.getItems(
@ -45,8 +48,9 @@ class DolphinPager(
).content
totalCount = result.totalRecordCount
}
}
override operator fun get(index: Int): BaseItemDto? {
override operator fun get(index: Int): BaseItem? {
if (index in 0..<totalCount) {
val item = items[index]
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) {
val item = items[position]
if (item == null) {
@ -89,7 +93,7 @@ class DolphinPager(
enableTotalRecordCount = false,
),
).content
val data = result.items
val data = result.items.map { BaseItem.from(it, api) }
cachedPages.put(pageNumber, data)
items = ItemList(totalCount, pageSize, cachedPages.asMap())
}