mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Lots of UI changes
This commit is contained in:
parent
6e185b25b7
commit
379559c076
16 changed files with 557 additions and 71 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
Text(text = item.primaryImageAspectRatio.toString())
|
||||
if (dto.type == BaseItemKind.EPISODE) {
|
||||
if (dto.parentIndexNumber != null && dto.indexNumber != null) {
|
||||
Text(
|
||||
text = "S${dto.parentIndexNumber} E${dto.indexNumber}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue