Merge branch 'main' into develop/mpv

This commit is contained in:
Damontecres 2025-11-10 10:16:14 -05:00
commit ad80f5bbe4
No known key found for this signature in database
10 changed files with 469 additions and 207 deletions

View file

@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -15,16 +17,23 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.HomeRow
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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
@ -34,33 +43,73 @@ import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject
@HiltViewModel @HiltViewModel(assistedFactory = RecommendedMovieViewModel.Factory::class)
class RecommendedMovieViewModel class RecommendedMovieViewModel
@Inject @AssistedInject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
@Assisted val parentId: UUID,
) : ViewModel() { ) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) @AssistedFactory
val rows = MutableLiveData<List<HomeRow>>() interface Factory {
fun create(parentId: UUID): RecommendedMovieViewModel
}
fun init( val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
preferences: UserPreferences,
parentId: UUID, val rows =
) { MutableStateFlow<MutableList<HomeRowLoadingState>>(
rowTitles
.map {
HomeRowLoadingState.Pending(
context.getString(it),
)
}.toMutableList(),
)
fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val resumeItemsRequest = try {
GetResumeItemsRequest( val resumeItemsRequest =
parentId = parentId, GetResumeItemsRequest(
fields = SlimItemFields, parentId = parentId,
includeItemTypes = listOf(BaseItemKind.MOVIE), fields = SlimItemFields,
enableUserData = true, includeItemTypes = listOf(BaseItemKind.MOVIE),
enableUserData = true,
)
val resumeItems =
ApiRequestPager(
api,
resumeItemsRequest,
GetResumeItemsRequestHandler,
viewModelScope,
).init()
if (resumeItems.isNotEmpty()) {
resumeItems.getBlocking(0)
}
update(
0,
HomeRowLoadingState.Success(
context.getString(R.string.continue_watching),
resumeItems,
),
) )
val resumeItems =
ApiRequestPager(api, resumeItemsRequest, GetResumeItemsRequestHandler, viewModelScope) withContext(Dispatchers.Main) {
loading.value = LoadingState.Success
}
} catch (ex: Exception) {
Timber.e(ex, "Exception fetching movie recommendations")
withContext(Dispatchers.Main) {
loading.value = LoadingState.Error(ex)
}
}
val recentlyReleasedRequest = val recentlyReleasedRequest =
GetItemsRequest( GetItemsRequest(
@ -109,22 +158,50 @@ class RecommendedMovieViewModel
val unwatchedTopRatedItems = val unwatchedTopRatedItems =
ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
val rows = listOf(resumeItems, recentlyReleasedItems, recentlyAddedItems, suggestedItems, unwatchedTopRatedItems) val rows =
rows.forEach { it.init() }
val homeRows =
listOf( listOf(
HomeRow(R.string.continue_watching, resumeItems), R.string.recently_released to recentlyReleasedItems,
HomeRow(R.string.recently_released, recentlyReleasedItems), R.string.recently_added to recentlyAddedItems,
HomeRow(R.string.recently_added, recentlyAddedItems), R.string.suggestions to suggestedItems,
HomeRow(R.string.suggestions, suggestedItems), R.string.top_unwatched to unwatchedTopRatedItems,
HomeRow(R.string.top_unwatched, unwatchedTopRatedItems), )
).filter { it.items.isNotEmpty() }
withContext(Dispatchers.Main) { rows.forEachIndexed { index, (title, pager) ->
this@RecommendedMovieViewModel.rows.value = homeRows viewModelScope.launchIO {
loading.value = LoadingState.Success val title = context.getString(title)
val result =
try {
pager.init()
HomeRowLoadingState.Success(title, pager)
} catch (ex: Exception) {
Timber.e(ex, "Error fetching %s", title)
HomeRowLoadingState.Error(title, null, ex)
}
update(index + 1, result)
}
} }
} }
} }
private fun update(
position: Int,
row: HomeRowLoadingState,
) {
rows.update { current ->
current.apply { set(position, row) }
}
}
companion object {
private val rowTitles =
listOf(
R.string.continue_watching,
R.string.recently_released,
R.string.recently_added,
R.string.suggestions,
R.string.top_unwatched,
)
}
} }
/** /**
@ -137,13 +214,16 @@ fun RecommendedMovie(
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
onFocusPosition: (RowColumn) -> Unit, onFocusPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: RecommendedMovieViewModel = hiltViewModel(), viewModel: RecommendedMovieViewModel =
hiltViewModel<RecommendedMovieViewModel, RecommendedMovieViewModel.Factory>(
creationCallback = { it.create(parentId) },
),
) { ) {
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
viewModel.init(preferences, parentId) viewModel.init()
} }
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val rows by viewModel.rows.observeAsState(listOf()) val rows by viewModel.rows.collectAsState(listOf())
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)

View file

@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -15,17 +17,24 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.HomeRow
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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
@ -36,41 +45,99 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject
@HiltViewModel @HiltViewModel(assistedFactory = RecommendedTvShowViewModel.Factory::class)
class RecommendedTvShowViewModel class RecommendedTvShowViewModel
@Inject @AssistedInject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
@Assisted val parentId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(parentId: UUID): RecommendedTvShowViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val rows = MutableLiveData<List<HomeRow>>()
fun init( val rows =
preferences: UserPreferences, MutableStateFlow<MutableList<HomeRowLoadingState>>(
parentId: UUID, rowTitles
) { .map {
HomeRowLoadingState.Pending(
context.getString(it),
)
}.toMutableList(),
)
fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val resumeItemsRequest = try {
GetResumeItemsRequest( val resumeItemsRequest =
parentId = parentId, GetResumeItemsRequest(
fields = SlimItemFields, parentId = parentId,
includeItemTypes = listOf(BaseItemKind.EPISODE), fields = SlimItemFields,
enableUserData = true, includeItemTypes = listOf(BaseItemKind.EPISODE),
) enableUserData = true,
val resumeItems = )
ApiRequestPager(api, resumeItemsRequest, GetResumeItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) val resumeItems =
ApiRequestPager(
api,
resumeItemsRequest,
GetResumeItemsRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
).init()
if (resumeItems.isNotEmpty()) {
resumeItems.getBlocking(0)
}
val nextUpRequest = val nextUpRequest =
GetNextUpRequest( GetNextUpRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
enableUserData = true, enableUserData = true,
)
val nextUpItems =
ApiRequestPager(
api,
nextUpRequest,
GetNextUpRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
).init()
if (nextUpItems.isNotEmpty()) {
nextUpItems.getBlocking(0)
}
update(
0,
HomeRowLoadingState.Success(
context.getString(R.string.continue_watching),
resumeItems,
),
) )
val nextUpItems = ApiRequestPager(api, nextUpRequest, GetNextUpRequestHandler, viewModelScope, useSeriesForPrimary = true) update(
1,
HomeRowLoadingState.Success(
context.getString(R.string.next_up),
nextUpItems,
),
)
withContext(Dispatchers.Main) {
loading.value = LoadingState.Success
}
} catch (ex: Exception) {
Timber.e(ex, "Exception fetching tv recommendations")
withContext(Dispatchers.Main) {
loading.value = LoadingState.Error(ex)
}
}
val recentlyReleasedRequest = val recentlyReleasedRequest =
GetItemsRequest( GetItemsRequest(
@ -120,23 +187,50 @@ class RecommendedTvShowViewModel
ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true) ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
val rows = val rows =
listOf(resumeItems, nextUpItems, recentlyReleasedItems, recentlyAddedItems, suggestedItems, unwatchedTopRatedItems)
rows.forEach { it.init() }
val homeRows =
listOf( listOf(
HomeRow(R.string.continue_watching, resumeItems), R.string.recently_released to recentlyReleasedItems,
HomeRow(R.string.next_up, nextUpItems), R.string.recently_added to recentlyAddedItems,
HomeRow(R.string.recently_released, recentlyReleasedItems), R.string.suggestions to suggestedItems,
HomeRow(R.string.recently_added, recentlyAddedItems), R.string.top_unwatched to unwatchedTopRatedItems,
HomeRow(R.string.suggestions, suggestedItems), )
HomeRow(R.string.top_unwatched, unwatchedTopRatedItems),
).filter { it.items.isNotEmpty() } rows.forEachIndexed { index, (title, pager) ->
withContext(Dispatchers.Main) { viewModelScope.launchIO {
this@RecommendedTvShowViewModel.rows.value = homeRows val title = context.getString(title)
loading.value = LoadingState.Success val result =
try {
pager.init()
HomeRowLoadingState.Success(title, pager)
} catch (ex: Exception) {
Timber.e(ex, "Error fetching %s", title)
HomeRowLoadingState.Error(title, null, ex)
}
update(index + 2, result)
}
} }
} }
} }
private fun update(
position: Int,
row: HomeRowLoadingState,
) {
rows.update { current ->
current.apply { set(position, row) }
}
}
companion object {
private val rowTitles =
listOf(
R.string.continue_watching,
R.string.next_up,
R.string.recently_released,
R.string.recently_added,
R.string.suggestions,
R.string.top_unwatched,
)
}
} }
/** /**
@ -149,13 +243,17 @@ fun RecommendedTvShow(
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
onFocusPosition: (RowColumn) -> Unit, onFocusPosition: (RowColumn) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: RecommendedTvShowViewModel = hiltViewModel(), viewModel: RecommendedTvShowViewModel =
hiltViewModel<RecommendedTvShowViewModel, RecommendedTvShowViewModel.Factory>(
creationCallback = { it.create(parentId) },
),
) { ) {
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
viewModel.init(preferences, parentId) viewModel.init()
} }
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val rows by viewModel.rows.observeAsState(listOf()) val rows by viewModel.rows.collectAsState(listOf())
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state) is LoadingState.Error -> ErrorMessage(state)

View file

@ -59,7 +59,7 @@ class MovieViewModel
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(itemId2: UUID): MovieViewModel fun create(itemId: UUID): MovieViewModel
} }
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)

View file

@ -24,18 +24,18 @@ import org.jellyfin.sdk.model.extensions.ticks
@Composable @Composable
fun FocusedEpisodeHeader( fun FocusedEpisodeHeader(
ep: BaseItem, ep: BaseItem?,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val dto = ep.data val dto = ep?.data
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier, modifier = modifier,
) { ) {
Text( Text(
text = dto.episodeTitle ?: dto.name ?: "", text = dto?.episodeTitle ?: dto?.name ?: "",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -46,17 +46,17 @@ fun FocusedEpisodeHeader(
) { ) {
val details = val details =
buildList { buildList {
dto.seasonEpisode?.let(::add) dto?.seasonEpisode?.let(::add)
dto.premiereDate?.let { add(formatDateTime(it)) } dto?.premiereDate?.let { add(formatDateTime(it)) }
val duration = dto.runTimeTicks?.ticks val duration = dto?.runTimeTicks?.ticks
duration duration
?.roundMinutes ?.roundMinutes
?.toString() ?.toString()
?.let { ?.let {
add(it) add(it)
} }
dto.officialRating?.let(::add) dto?.officialRating?.let(::add)
dto.timeRemaining?.roundMinutes?.let { add("$it left") } dto?.timeRemaining?.roundMinutes?.let { add("$it left") }
} }
DotSeparatedRow( DotSeparatedRow(
texts = details, texts = details,
@ -68,12 +68,12 @@ fun FocusedEpisodeHeader(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
SimpleStarRating( SimpleStarRating(
dto.communityRating, dto?.communityRating,
Modifier.height(20.dp), Modifier.height(20.dp),
) )
} }
OverviewText( OverviewText(
overview = dto.overview ?: "", overview = dto?.overview ?: "",
maxLines = 3, maxLines = 3,
onClick = overviewOnClick, onClick = overviewOnClick,
) )

View file

@ -162,13 +162,11 @@ fun SeriesOverviewContent(
} }
item { item {
// Episode header // Episode header
focusedEpisode?.let { ep -> FocusedEpisodeHeader(
FocusedEpisodeHeader( ep = focusedEpisode,
ep = ep, overviewOnClick = overviewOnClick,
overviewOnClick = overviewOnClick, modifier = Modifier.fillMaxWidth(.66f),
modifier = Modifier.fillMaxWidth(.66f), )
)
}
} }
item { item {
key(position.seasonTabIndex) { key(position.seasonTabIndex) {

View file

@ -293,17 +293,11 @@ class SeriesViewModel
itemId: UUID, itemId: UUID,
listIndex: Int, listIndex: Int,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val base = api.userLibraryApi.getItem(itemId).content
val item = BaseItem.Companion.from(base, api)
val eps = episodes.value val eps = episodes.value
if (eps is EpisodeList.Success) { if (eps is EpisodeList.Success) {
val newItems = eps.episodes.refreshItem(listIndex, itemId)
eps.episodes.toMutableList().apply {
this[listIndex] = item
}
val newValue = eps.copy(episodes = newItems)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
episodes.value = newValue episodes.value = eps
} }
} }
} }
@ -409,7 +403,7 @@ sealed interface EpisodeList {
} }
data class Success( data class Success(
val episodes: List<BaseItem?>, val episodes: ApiRequestPager<GetEpisodesRequest>,
val initialIndex: Int, val initialIndex: Int,
) : EpisodeList ) : EpisodeList
} }

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.ui.main package com.github.damontecres.wholphin.ui.main
import android.widget.Toast import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -67,6 +66,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.timeRemaining import com.github.damontecres.wholphin.ui.timeRemaining
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.formatDateTime import com.github.damontecres.wholphin.util.formatDateTime
import com.github.damontecres.wholphin.util.seasonEpisode import com.github.damontecres.wholphin.util.seasonEpisode
@ -77,22 +77,6 @@ import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
data class HomeRow(
@param:StringRes val titleRes: Int?,
val title: String?,
val items: List<BaseItem?>,
) {
constructor(
@StringRes titleRes: Int,
items: List<BaseItem?>,
) : this(titleRes, null, items)
constructor(
title: String,
items: List<BaseItem?>,
) : this(null, title, items)
}
@Composable @Composable
fun HomePage( fun HomePage(
preferences: UserPreferences, preferences: UserPreferences,
@ -203,7 +187,7 @@ fun HomePage(
@Composable @Composable
fun HomePageContent( fun HomePageContent(
homeRows: List<HomeRow>, homeRows: List<HomeRowLoadingState>,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit, onLongClickItem: (BaseItem) -> Unit,
showClock: Boolean, showClock: Boolean,
@ -215,7 +199,10 @@ fun HomePageContent(
var position by rememberSaveable(stateSaver = RowColumnSaver) { var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(0, 0)) mutableStateOf(RowColumn(0, 0))
} }
var focusedItem = position.let { homeRows.getOrNull(it.row)?.items?.getOrNull(it.column) } var focusedItem =
position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
}
val listState = rememberLazyListState() val listState = rememberLazyListState()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -284,55 +271,101 @@ fun HomePageContent(
modifier = Modifier, modifier = Modifier,
) { ) {
itemsIndexed(homeRows) { rowIndex, row -> itemsIndexed(homeRows) { rowIndex, row ->
if (row.items.isNotEmpty()) { when (val r = row) {
ItemRow( is HomeRowLoadingState.Loading,
title = row.title ?: row.titleRes?.let { stringResource(it) } ?: "", is HomeRowLoadingState.Pending,
items = row.items, -> {
onClickItem = onClickItem, Column(
cardOnFocus = { isFocused, index -> verticalArrangement = Arrangement.spacedBy(8.dp),
if (isFocused) { modifier = modifier,
focusedItem = row.items.getOrNull(index) ) {
position = RowColumn(rowIndex, index) Text(
} text = r.title,
}, style = MaterialTheme.typography.titleLarge,
onLongClickItem = onLongClickItem, color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.fillMaxWidth(),
cardContent = { index, item, cardModifier, onClick, onLongClick ->
// TODO better aspect ration handling?
BannerCard(
name = item?.data?.seriesName ?: item?.name,
imageUrl = item?.imageUrl,
aspectRatio = (2f / 3f),
cornerText =
item?.data?.indexNumber?.let { "E$it" }
?: item?.data?.childCount?.let { if (it > 0) it.toString() else null },
played = item?.data?.userData?.played ?: false,
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier =
cardModifier
.ifElse(
focusedItem == item,
Modifier.focusRequester(focusRequester),
).ifElse(
RowColumn(rowIndex, index) == position,
Modifier.focusRequester(positionFocusRequester),
).onFocusChanged {
if (it.isFocused) {
onFocusPosition?.invoke(
RowColumn(
rowIndex,
index,
),
)
}
},
interactionSource = null,
cardHeight = Cards.height2x3,
) )
}, Text(
) text = stringResource(R.string.loading),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
is HomeRowLoadingState.Error -> {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = r.title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = r.localizedMessage,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
is HomeRowLoadingState.Success -> {
if (row.items.isNotEmpty()) {
ItemRow(
title = row.title,
items = row.items,
onClickItem = onClickItem,
cardOnFocus = { isFocused, index ->
if (isFocused) {
focusedItem = row.items.getOrNull(index)
position = RowColumn(rowIndex, index)
}
},
onLongClickItem = onLongClickItem,
modifier = Modifier.fillMaxWidth(),
cardContent = { index, item, cardModifier, onClick, onLongClick ->
// TODO better aspect ration handling?
BannerCard(
name = item?.data?.seriesName ?: item?.name,
imageUrl = item?.imageUrl,
aspectRatio = (2f / 3f),
cornerText =
item?.data?.indexNumber?.let { "E$it" }
?: item?.data?.childCount?.let { if (it > 0) it.toString() else null },
played = item?.data?.userData?.played ?: false,
playPercent =
item?.data?.userData?.playedPercentage
?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier =
cardModifier
.ifElse(
focusedItem == item,
Modifier.focusRequester(focusRequester),
).ifElse(
RowColumn(rowIndex, index) == position,
Modifier.focusRequester(
positionFocusRequester,
),
).onFocusChanged {
if (it.isFocused) {
onFocusPosition?.invoke(
RowColumn(
rowIndex,
index,
),
)
}
},
interactionSource = null,
cardHeight = Cards.height2x3,
)
},
)
}
}
} }
} }
} }

View file

@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
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 com.github.damontecres.wholphin.util.supportItemKinds import com.github.damontecres.wholphin.util.supportItemKinds
@ -50,7 +51,7 @@ class HomeViewModel
val navDrawerItemRepository: NavDrawerItemRepository, val navDrawerItemRepository: NavDrawerItemRepository,
) : ViewModel() { ) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
val homeRows = MutableLiveData<List<HomeRow>>() val homeRows = MutableLiveData<List<HomeRowLoadingState>>()
private lateinit var preferences: UserPreferences private lateinit var preferences: UserPreferences
@ -89,25 +90,21 @@ class HomeViewModel
buildList { buildList {
if (resume.isNotEmpty()) { if (resume.isNotEmpty()) {
add( add(
HomeRow( HomeRowLoadingState.Success(
titleRes = R.string.continue_watching, title = context.getString(R.string.continue_watching),
items = resume, items = resume,
), ),
) )
} }
if (nextUp.isNotEmpty()) { if (nextUp.isNotEmpty()) {
add( add(
HomeRow( HomeRowLoadingState.Success(
titleRes = R.string.next_up, title = context.getString(R.string.next_up),
items = nextUp, items = nextUp,
), ),
) )
} }
latest.forEach { addAll(latest)
if (it.items.isNotEmpty()) {
add(it)
}
}
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@HomeViewModel.homeRows.value = homeRows this@HomeViewModel.homeRows.value = homeRows
@ -177,7 +174,7 @@ class HomeViewModel
user: UserDto, user: UserDto,
limit: Int, limit: Int,
includedIds: List<UUID>, includedIds: List<UUID>,
): List<HomeRow> { ): List<HomeRowLoadingState> {
val latestMediaIncludes = val latestMediaIncludes =
user.configuration user.configuration
?.orderedViews ?.orderedViews
@ -199,38 +196,47 @@ class HomeViewModel
} else { } else {
view.name?.let { context.getString(R.string.recently_added_in, it) } view.name?.let { context.getString(R.string.recently_added_in, it) }
} ?: context.getString(R.string.recently_added) } ?: context.getString(R.string.recently_added)
val viewId = try {
if (view.collectionType == CollectionType.LIVETV) { val viewId =
api.liveTvApi if (view.collectionType == CollectionType.LIVETV) {
.getRecordingFolders( api.liveTvApi
userId = user.id, .getRecordingFolders(
).content.items userId = user.id,
.firstOrNull() ).content.items
?.id .firstOrNull()
} else { ?.id
view.id } else {
} view.id
viewId?.let { }
val request = viewId?.let {
GetLatestMediaRequest( val request =
fields = SlimItemFields, GetLatestMediaRequest(
imageTypeLimit = 1, fields = SlimItemFields,
parentId = viewId, imageTypeLimit = 1,
groupItems = true, parentId = viewId,
limit = limit, groupItems = true,
isPlayed = null, // Server will handle user's preference limit = limit,
isPlayed = null, // Server will handle user's preference
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.from(it, api, true) }
HomeRowLoadingState.Success(
title = title,
items = latest,
) )
val latest = }
api.userLibraryApi } catch (ex: Exception) {
.getLatestMedia(request) Timber.e(ex, "Exception fetching %s", title)
.content HomeRowLoadingState.Error(
.map { BaseItem.from(it, api, true) }
HomeRow(
title = title, title = title,
items = latest, exception = ex,
) )
} }
} }
return rows return rows
} }

View file

@ -21,6 +21,7 @@ import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.GetProgramsDto import org.jellyfin.sdk.model.api.GetProgramsDto
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
@ -31,6 +32,7 @@ import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber import timber.log.Timber
import java.util.UUID
import java.util.function.Predicate import java.util.function.Predicate
/** /**
@ -58,7 +60,7 @@ class ApiRequestPager<T>(
CacheBuilder CacheBuilder
.newBuilder() .newBuilder()
.maximumSize(cacheSize) .maximumSize(cacheSize)
.build<Int, List<BaseItem>>() .build<Int, MutableList<BaseItem>>()
suspend fun init(): ApiRequestPager<T> { suspend fun init(): ApiRequestPager<T> {
if (totalCount < 0) { if (totalCount < 0) {
@ -122,13 +124,38 @@ class ApiRequestPager<T>(
false, false,
) )
val result = requestHandler.execute(api, newRequest).content val result = requestHandler.execute(api, newRequest).content
val data = result.items.map { BaseItem.from(it, api, useSeriesForPrimary) } val data = mutableListOf<BaseItem>()
result.items.forEach { data.add(BaseItem.from(it, api, useSeriesForPrimary)) }
cachedPages.put(pageNumber, data) cachedPages.put(pageNumber, data)
items = ItemList(totalCount, pageSize, cachedPages.asMap()) items = ItemList(totalCount, pageSize, cachedPages.asMap())
} }
} }
} }
suspend fun refreshItem(
position: Int,
itemId: UUID,
) {
val item =
api.userLibraryApi.getItem(itemId).content.let {
BaseItem.from(
it,
api,
useSeriesForPrimary,
)
}
val pageNumber = position / pageSize
val index = position - pageNumber * pageSize
mutex.withLock {
val page = cachedPages.getIfPresent(pageNumber)
if (page != null && index in page.indices) {
page[index] = item
cachedPages.put(pageNumber, page)
items = ItemList(totalCount, pageSize, cachedPages.asMap())
}
}
}
companion object { companion object {
private const val DEBUG = false private const val DEBUG = false
} }

View file

@ -42,3 +42,29 @@ sealed interface RowLoadingState {
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
} }
} }
sealed interface HomeRowLoadingState {
val title: String
data class Pending(
override val title: String,
) : HomeRowLoadingState
data class Loading(
override val title: String,
) : HomeRowLoadingState
data class Success(
override val title: String,
val items: List<BaseItem?>,
) : HomeRowLoadingState
data class Error(
override val title: String,
val message: String? = null,
val exception: Throwable? = null,
) : HomeRowLoadingState {
val localizedMessage: String =
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
}
}