mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Add page to details about people (#119)
Adds a page to show details about people (actors, directors, etc) when clicking on the person card <img width="1280" height="771" alt="wholphin_person" src="https://github.com/user-attachments/assets/ceba6c77-2943-4faf-a2c2-81471fb3acff" />
This commit is contained in:
parent
90f4ca7306
commit
982506555f
10 changed files with 635 additions and 20 deletions
|
|
@ -17,6 +17,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -39,6 +40,7 @@ fun ItemRow(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
focusPair: FocusPair? = null,
|
focusPair: FocusPair? = null,
|
||||||
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
|
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
|
||||||
|
horizontalPadding: Dp = 16.dp,
|
||||||
) {
|
) {
|
||||||
val state = rememberLazyListState()
|
val state = rememberLazyListState()
|
||||||
val firstFocus = remember { FocusRequester() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
|
|
@ -54,8 +56,8 @@ fun ItemRow(
|
||||||
)
|
)
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = state,
|
state = state,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(horizontalPadding),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 8.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
|
import com.github.damontecres.wholphin.util.RowLoadingState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingRow(
|
||||||
|
title: String,
|
||||||
|
state: RowLoadingState,
|
||||||
|
rowIndex: Int,
|
||||||
|
position: RowColumn,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
onClickItem: (BaseItem) -> Unit,
|
||||||
|
onClickPosition: (RowColumn) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showIfEmpty: Boolean = true,
|
||||||
|
horizontalPadding: Dp = 16.dp,
|
||||||
|
cardContent: @Composable (
|
||||||
|
index: Int,
|
||||||
|
item: BaseItem?,
|
||||||
|
modifier: Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
) -> Unit = @Composable { index, item, mod, onClick, onLongClick ->
|
||||||
|
SeasonCard(
|
||||||
|
item = item,
|
||||||
|
onClick = {
|
||||||
|
onClickPosition.invoke(RowColumn(rowIndex, index))
|
||||||
|
onClick.invoke()
|
||||||
|
},
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
imageHeight = Cards.height2x3,
|
||||||
|
modifier =
|
||||||
|
mod
|
||||||
|
.ifElse(
|
||||||
|
position.row == rowIndex && position.column == index,
|
||||||
|
Modifier.focusRequester(focusRequester),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
when (val r = state) {
|
||||||
|
is RowLoadingState.Error ->
|
||||||
|
LoadingRowPlaceholder(
|
||||||
|
title = title,
|
||||||
|
message = r.localizedMessage,
|
||||||
|
messageColor = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
RowLoadingState.Pending,
|
||||||
|
RowLoadingState.Loading,
|
||||||
|
->
|
||||||
|
LoadingRowPlaceholder(
|
||||||
|
title = title,
|
||||||
|
message = "Loading...",
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
is RowLoadingState.Success -> {
|
||||||
|
if (r.items.isNotEmpty()) {
|
||||||
|
ItemRow(
|
||||||
|
title = title,
|
||||||
|
items = r.items,
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onLongClickItem = {},
|
||||||
|
modifier = modifier,
|
||||||
|
cardContent = cardContent,
|
||||||
|
horizontalPadding = horizontalPadding,
|
||||||
|
)
|
||||||
|
} else if (showIfEmpty) {
|
||||||
|
LoadingRowPlaceholder(
|
||||||
|
title = title,
|
||||||
|
message = "No results",
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingRowPlaceholder(
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
messageColor: Color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier.padding(bottom = 32.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = messageColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,12 @@ import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
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.imageApi
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
|
|
@ -66,14 +67,14 @@ abstract class LoadingItemViewModel(
|
||||||
open fun init(
|
open fun init(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
potential: BaseItem?,
|
potential: BaseItem?,
|
||||||
): Job? {
|
): Deferred<BaseItem?> =
|
||||||
loading.value = LoadingState.Loading
|
viewModelScope.async(
|
||||||
return viewModelScope.launch(
|
|
||||||
LoadingExceptionHandler(
|
LoadingExceptionHandler(
|
||||||
loading,
|
loading,
|
||||||
"Error loading item $itemId",
|
"Error loading item $itemId",
|
||||||
) + Dispatchers.IO,
|
) + Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
|
loading.setValueOnMain(LoadingState.Loading)
|
||||||
try {
|
try {
|
||||||
fetchAndSetItem(itemId)
|
fetchAndSetItem(itemId)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -82,16 +83,18 @@ abstract class LoadingItemViewModel(
|
||||||
item.value = null
|
item.value = null
|
||||||
loading.value = LoadingState.Error("Error loading item $itemId", e)
|
loading.value = LoadingState.Error("Error loading item $itemId", e)
|
||||||
}
|
}
|
||||||
}
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
open suspend fun fetchAndSetItem(itemId: UUID) =
|
open suspend fun fetchAndSetItem(itemId: UUID): BaseItem =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val fetchedItem = api.userLibraryApi.getItem(itemId).content
|
val fetchedItem = api.userLibraryApi.getItem(itemId).content
|
||||||
|
val item = BaseItem.from(fetchedItem, api)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
item.value = BaseItem.from(fetchedItem, api)
|
this@LoadingItemViewModel.item.value = item
|
||||||
loading.value = LoadingState.Success
|
loading.value = LoadingState.Success
|
||||||
}
|
}
|
||||||
|
return@withContext item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,427 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.detail
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shadow
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||||
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.LoadingRow
|
||||||
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
|
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.RowLoadingState
|
||||||
|
import com.github.damontecres.wholphin.util.formatDate
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class PersonViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
api: ApiClient,
|
||||||
|
val navigationManager: NavigationManager,
|
||||||
|
) : LoadingItemViewModel(api) {
|
||||||
|
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||||
|
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||||
|
val episodes = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
|
||||||
|
|
||||||
|
fun init(itemId: UUID) {
|
||||||
|
viewModelScope.launchIO(
|
||||||
|
LoadingExceptionHandler(
|
||||||
|
loading,
|
||||||
|
"Error loading item $itemId",
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
super.init(itemId, null).await()?.let { person ->
|
||||||
|
val dto = person.data
|
||||||
|
if ((dto.movieCount ?: 0) > 0) {
|
||||||
|
fetchRow(person.id, BaseItemKind.MOVIE, movies)
|
||||||
|
} else {
|
||||||
|
movies.setValueOnMain(RowLoadingState.Success(listOf()))
|
||||||
|
}
|
||||||
|
if ((dto.seriesCount ?: 0) > 0) {
|
||||||
|
fetchRow(person.id, BaseItemKind.SERIES, series)
|
||||||
|
} else {
|
||||||
|
series.setValueOnMain(RowLoadingState.Success(listOf()))
|
||||||
|
}
|
||||||
|
if ((dto.episodeCount ?: 0) > 0) {
|
||||||
|
fetchRow(person.id, BaseItemKind.EPISODE, episodes)
|
||||||
|
} else {
|
||||||
|
episodes.setValueOnMain(RowLoadingState.Success(listOf()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchRow(
|
||||||
|
itemId: UUID,
|
||||||
|
type: BaseItemKind,
|
||||||
|
target: MutableLiveData<RowLoadingState>,
|
||||||
|
) {
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
target.setValueOnMain(RowLoadingState.Loading)
|
||||||
|
try {
|
||||||
|
val request =
|
||||||
|
GetItemsRequest(
|
||||||
|
personIds = listOf(itemId),
|
||||||
|
includeItemTypes = listOf(type),
|
||||||
|
fields = SlimItemFields,
|
||||||
|
recursive = true,
|
||||||
|
sortBy = listOf(ItemSortBy.PREMIERE_DATE, ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME),
|
||||||
|
sortOrder = listOf(SortOrder.DESCENDING, SortOrder.DESCENDING, SortOrder.ASCENDING),
|
||||||
|
)
|
||||||
|
val pager =
|
||||||
|
ApiRequestPager(
|
||||||
|
api,
|
||||||
|
request,
|
||||||
|
GetItemsRequestHandler,
|
||||||
|
viewModelScope,
|
||||||
|
pageSize = 15,
|
||||||
|
useSeriesForPrimary = false,
|
||||||
|
).init()
|
||||||
|
target.setValueOnMain(RowLoadingState.Success(pager))
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.e(ex, "Error fetching $type for $itemId")
|
||||||
|
target.setValueOnMain(RowLoadingState.Error(ex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PersonPage(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
destination: Destination.MediaItem,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: PersonViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
OneTimeLaunchedEffect {
|
||||||
|
viewModel.init(destination.itemId)
|
||||||
|
}
|
||||||
|
val person by viewModel.item.observeAsState()
|
||||||
|
val movies by viewModel.movies.observeAsState(RowLoadingState.Pending)
|
||||||
|
val series by viewModel.series.observeAsState(RowLoadingState.Pending)
|
||||||
|
val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending)
|
||||||
|
|
||||||
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
|
when (val state = loading) {
|
||||||
|
is LoadingState.Error -> ErrorMessage(state, modifier)
|
||||||
|
LoadingState.Loading,
|
||||||
|
LoadingState.Pending,
|
||||||
|
-> LoadingPage(modifier)
|
||||||
|
|
||||||
|
LoadingState.Success -> {
|
||||||
|
person?.let { person ->
|
||||||
|
var showOverviewDialog by remember { mutableStateOf(false) }
|
||||||
|
val name = person.name ?: person.id.toString()
|
||||||
|
PersonPageContent(
|
||||||
|
preferences = preferences,
|
||||||
|
name = name,
|
||||||
|
overview = person.data.overview,
|
||||||
|
imageUrl = person.imageUrl,
|
||||||
|
birthdate = person.data.premiereDate?.toLocalDate(),
|
||||||
|
deathdate = person.data.endDate?.toLocalDate(),
|
||||||
|
birthPlace = person.data.productionLocations?.firstOrNull(),
|
||||||
|
movies = movies,
|
||||||
|
series = series,
|
||||||
|
episodes = episodes,
|
||||||
|
onClickItem = {
|
||||||
|
viewModel.navigationManager.navigateTo(it.destination())
|
||||||
|
},
|
||||||
|
overviewOnClick = { showOverviewDialog = true },
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
AnimatedVisibility(showOverviewDialog) {
|
||||||
|
ItemDetailsDialog(
|
||||||
|
info =
|
||||||
|
ItemDetailsDialogInfo(
|
||||||
|
title = name,
|
||||||
|
overview = person.data.overview,
|
||||||
|
files = listOf(),
|
||||||
|
),
|
||||||
|
onDismissRequest = { showOverviewDialog = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val HEADER_ROW = 0
|
||||||
|
private const val MOVIE_ROW = 1
|
||||||
|
private const val SERIES_ROW = 2
|
||||||
|
private const val EPISODE_ROW = 3
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PersonPageContent(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
name: String,
|
||||||
|
overview: String?,
|
||||||
|
imageUrl: String?,
|
||||||
|
birthdate: LocalDate?,
|
||||||
|
deathdate: LocalDate?,
|
||||||
|
birthPlace: String?,
|
||||||
|
movies: RowLoadingState,
|
||||||
|
series: RowLoadingState,
|
||||||
|
episodes: RowLoadingState,
|
||||||
|
onClickItem: (BaseItem) -> Unit,
|
||||||
|
overviewOnClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val headerFocusRequester = remember { FocusRequester() }
|
||||||
|
var position by rememberPosition()
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (position.row > HEADER_ROW) {
|
||||||
|
focusRequester.tryRequestFocus()
|
||||||
|
} else {
|
||||||
|
headerFocusRequester.tryRequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.padding(start = 16.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
PersonHeader(
|
||||||
|
name = name,
|
||||||
|
overview = overview,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
birthdate = birthdate,
|
||||||
|
birthPlace = birthPlace,
|
||||||
|
deathdate = deathdate,
|
||||||
|
overviewOnClick = overviewOnClick,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 480.dp)
|
||||||
|
.padding(top = 16.dp, bottom = 40.dp)
|
||||||
|
.focusRequester(headerFocusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
LoadingRow(
|
||||||
|
title = rowTitle("Movies", movies),
|
||||||
|
state = movies,
|
||||||
|
rowIndex = MOVIE_ROW,
|
||||||
|
position = position,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onClickPosition = { position = it },
|
||||||
|
showIfEmpty = false,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
LoadingRow(
|
||||||
|
title = rowTitle("Series", series),
|
||||||
|
state = series,
|
||||||
|
rowIndex = SERIES_ROW,
|
||||||
|
position = position,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onClickPosition = { position = it },
|
||||||
|
showIfEmpty = false,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
LoadingRow(
|
||||||
|
title = rowTitle("Episodes", episodes),
|
||||||
|
state = episodes,
|
||||||
|
rowIndex = EPISODE_ROW,
|
||||||
|
position = position,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onClickPosition = { position = it },
|
||||||
|
showIfEmpty = false,
|
||||||
|
horizontalPadding = 32.dp,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rowTitle(
|
||||||
|
prefix: String,
|
||||||
|
state: RowLoadingState,
|
||||||
|
): String =
|
||||||
|
if (state is RowLoadingState.Success) {
|
||||||
|
"$prefix (${state.items.size})"
|
||||||
|
} else {
|
||||||
|
prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PersonHeader(
|
||||||
|
name: String,
|
||||||
|
overview: String?,
|
||||||
|
imageUrl: String?,
|
||||||
|
birthdate: LocalDate?,
|
||||||
|
deathdate: LocalDate?,
|
||||||
|
birthPlace: String?,
|
||||||
|
overviewOnClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = name,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
// .fillMaxWidth(.25f)
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(10)),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
// .fillMaxWidth(.7f)
|
||||||
|
.weight(3f)
|
||||||
|
.padding(top = 16.dp, end = 32.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), RoundedCornerShape(10))
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.displaySmall.copy(
|
||||||
|
shadow =
|
||||||
|
Shadow(
|
||||||
|
color = Color.DarkGray,
|
||||||
|
offset = Offset(5f, 2f),
|
||||||
|
blurRadius = 2f,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
birthdate?.let {
|
||||||
|
val age = if (deathdate == null) birthdate.until(LocalDate.now())?.years else null
|
||||||
|
val text =
|
||||||
|
if (age != null) {
|
||||||
|
"Born: ${formatDate(it)} ($age years old)"
|
||||||
|
} else {
|
||||||
|
"Born: ${formatDate(it)}"
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
birthPlace?.let {
|
||||||
|
Text(
|
||||||
|
text = "Birthplace: $it",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
deathdate?.let {
|
||||||
|
val age = birthdate?.until(it)?.years
|
||||||
|
val text =
|
||||||
|
if (age != null) {
|
||||||
|
"Died: ${formatDate(it)} ($age years old)"
|
||||||
|
} else {
|
||||||
|
"Died: ${formatDate(it)}"
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OverviewText(
|
||||||
|
overview = overview ?: "",
|
||||||
|
maxLines = 3,
|
||||||
|
onClick = overviewOnClick,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTvSpec
|
||||||
|
@Composable
|
||||||
|
private fun PersonPreview() {
|
||||||
|
WholphinTheme {
|
||||||
|
PersonHeader(
|
||||||
|
name = "John Smith",
|
||||||
|
overview = "John Smith is an actor",
|
||||||
|
imageUrl = null,
|
||||||
|
birthdate = LocalDate.of(1975, 3, 22),
|
||||||
|
birthPlace = "Phoenix, Arizona, USA",
|
||||||
|
deathdate = LocalDate.of(2025, 2, 1),
|
||||||
|
overviewOnClick = {},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,7 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
@ -87,7 +88,7 @@ fun MovieDetails(
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.init(destination.itemId, destination.item)
|
viewModel.init(destination.itemId)
|
||||||
}
|
}
|
||||||
val item by viewModel.item.observeAsState()
|
val item by viewModel.item.observeAsState()
|
||||||
val people by viewModel.people.observeAsState(listOf())
|
val people by viewModel.people.observeAsState(listOf())
|
||||||
|
|
@ -119,6 +120,14 @@ fun MovieDetails(
|
||||||
onClickItem = {
|
onClickItem = {
|
||||||
viewModel.navigationManager.navigateTo(it.destination())
|
viewModel.navigationManager.navigateTo(it.destination())
|
||||||
},
|
},
|
||||||
|
onClickPerson = {
|
||||||
|
viewModel.navigationManager.navigateTo(
|
||||||
|
Destination.MediaItem(
|
||||||
|
it.id,
|
||||||
|
BaseItemKind.PERSON,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
playOnClick = {
|
playOnClick = {
|
||||||
viewModel.navigationManager.navigateTo(
|
viewModel.navigationManager.navigateTo(
|
||||||
Destination.Playback(
|
Destination.Playback(
|
||||||
|
|
@ -266,6 +275,7 @@ fun MovieDetailsContent(
|
||||||
favoriteOnClick: () -> Unit,
|
favoriteOnClick: () -> Unit,
|
||||||
moreOnClick: () -> Unit,
|
moreOnClick: () -> Unit,
|
||||||
onClickItem: (BaseItem) -> Unit,
|
onClickItem: (BaseItem) -> Unit,
|
||||||
|
onClickPerson: (Person) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
@ -361,6 +371,7 @@ fun MovieDetailsContent(
|
||||||
people = people,
|
people = people,
|
||||||
onClick = {
|
onClick = {
|
||||||
position = PEOPLE_ROW
|
position = PEOPLE_ROW
|
||||||
|
onClickPerson.invoke(it)
|
||||||
},
|
},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
modifier =
|
modifier =
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,7 @@ class MovieViewModel
|
||||||
val similar = MutableLiveData<List<BaseItem>>()
|
val similar = MutableLiveData<List<BaseItem>>()
|
||||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||||
|
|
||||||
override fun init(
|
fun init(itemId: UUID): Job? {
|
||||||
itemId: UUID,
|
|
||||||
potential: BaseItem?,
|
|
||||||
): Job? {
|
|
||||||
this.itemId = itemId
|
this.itemId = itemId
|
||||||
return viewModelScope.launch(
|
return viewModelScope.launch(
|
||||||
Dispatchers.IO +
|
Dispatchers.IO +
|
||||||
|
|
@ -103,9 +100,9 @@ class MovieViewModel
|
||||||
people.value =
|
people.value =
|
||||||
item.data.people
|
item.data.people
|
||||||
?.letNotEmpty { people ->
|
?.letNotEmpty { people ->
|
||||||
people.map { Person.Companion.fromDto(it, api) }
|
people.map { Person.fromDto(it, api) }
|
||||||
}.orEmpty()
|
}.orEmpty()
|
||||||
chapters.value = Chapter.Companion.fromDto(item.data, api)
|
chapters.value = Chapter.fromDto(item.data, api)
|
||||||
}
|
}
|
||||||
if (!similar.isInitialized) {
|
if (!similar.isInitialized) {
|
||||||
val similar =
|
val similar =
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -123,6 +124,14 @@ fun SeriesDetails(
|
||||||
favorite = item.data.userData?.isFavorite ?: false,
|
favorite = item.data.userData?.isFavorite ?: false,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClickItem = { viewModel.navigateTo(it.destination()) },
|
onClickItem = { viewModel.navigateTo(it.destination()) },
|
||||||
|
onClickPerson = {
|
||||||
|
viewModel.navigateTo(
|
||||||
|
Destination.MediaItem(
|
||||||
|
it.id,
|
||||||
|
BaseItemKind.PERSON,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
onLongClickItem = { season ->
|
onLongClickItem = { season ->
|
||||||
seasonDialog =
|
seasonDialog =
|
||||||
buildDialogForSeason(
|
buildDialogForSeason(
|
||||||
|
|
@ -196,6 +205,7 @@ fun SeriesDetailsContent(
|
||||||
played: Boolean,
|
played: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
onClickItem: (BaseItem) -> Unit,
|
onClickItem: (BaseItem) -> Unit,
|
||||||
|
onClickPerson: (Person) -> Unit,
|
||||||
onLongClickItem: (BaseItem) -> Unit,
|
onLongClickItem: (BaseItem) -> Unit,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
playOnClick: () -> Unit,
|
playOnClick: () -> Unit,
|
||||||
|
|
@ -361,6 +371,7 @@ fun SeriesDetailsContent(
|
||||||
people = people,
|
people = people,
|
||||||
onClick = {
|
onClick = {
|
||||||
position = PEOPLE_ROW
|
position = PEOPLE_ROW
|
||||||
|
onClickPerson.invoke(it)
|
||||||
},
|
},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
modifier =
|
modifier =
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
|
||||||
import com.github.damontecres.wholphin.ui.detail.DebugPage
|
import com.github.damontecres.wholphin.ui.detail.DebugPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
|
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
|
||||||
|
import com.github.damontecres.wholphin.ui.detail.PersonPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails
|
||||||
|
|
@ -173,6 +174,13 @@ fun DestinationContent(
|
||||||
modifier,
|
modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BaseItemKind.PERSON ->
|
||||||
|
PersonPage(
|
||||||
|
preferences,
|
||||||
|
destination,
|
||||||
|
modifier,
|
||||||
|
)
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Timber.w("Unsupported item type: ${destination.type}")
|
Timber.w("Unsupported item type: ${destination.type}")
|
||||||
Text("Unsupported item type: ${destination.type}")
|
Text("Unsupported item type: ${destination.type}")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
@ -32,6 +33,18 @@ fun formatDateTime(dateTime: LocalDateTime): String =
|
||||||
dateTime.toString()
|
dateTime.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun formatDate(dateTime: LocalDate): String =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
// TODO server returns in UTC, but sdk converts to local time
|
||||||
|
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
|
||||||
|
formatter.format(dateTime)
|
||||||
|
} else if (dateTime.toString().length >= 10) {
|
||||||
|
dateTime.toString().substring(0, 10)
|
||||||
|
} else {
|
||||||
|
dateTime.toString()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the item has season & episode info, format as `S# E#`
|
* If the item has season & episode info, format as `S# E#`
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
package com.github.damontecres.wholphin.util
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic state for loading something from the API
|
* Generic state for loading something from the API
|
||||||
*/
|
*/
|
||||||
sealed interface LoadingState {
|
sealed interface LoadingState {
|
||||||
object Pending : LoadingState
|
data object Pending : LoadingState
|
||||||
|
|
||||||
object Loading : LoadingState
|
data object Loading : LoadingState
|
||||||
|
|
||||||
object Success : LoadingState
|
data object Success : LoadingState
|
||||||
|
|
||||||
data class Error(
|
data class Error(
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
|
|
@ -20,3 +22,23 @@ sealed interface LoadingState {
|
||||||
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
|
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed interface RowLoadingState {
|
||||||
|
data object Pending : RowLoadingState
|
||||||
|
|
||||||
|
data object Loading : RowLoadingState
|
||||||
|
|
||||||
|
data class Success(
|
||||||
|
val items: List<BaseItem?>,
|
||||||
|
) : RowLoadingState
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val message: String? = null,
|
||||||
|
val exception: Throwable? = null,
|
||||||
|
) : RowLoadingState {
|
||||||
|
constructor(exception: Throwable) : this(null, exception)
|
||||||
|
|
||||||
|
val localizedMessage: String =
|
||||||
|
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue