Merge branch 'main' into develop/mpv

This commit is contained in:
Damontecres 2025-11-14 16:16:40 -05:00
commit e3efb2e500
No known key found for this signature in database
23 changed files with 166 additions and 97 deletions

View file

@ -5,13 +5,13 @@ import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map import androidx.lifecycle.map
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -42,7 +42,7 @@ class ServerRepository
) { ) {
private val sharedPreferences = getServerSharedPreferences(context) private val sharedPreferences = getServerSharedPreferences(context)
private var _current = MutableLiveData<CurrentUser?>(null) private var _current = EqualityMutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current val current: LiveData<CurrentUser?> = _current
val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server } val currentServer: LiveData<JellyfinServer?> get() = _current.map { it?.server }

View file

@ -74,6 +74,12 @@ object Cards {
val playedPercentHeight = 6.dp val playedPercentHeight = 6.dp
} }
object AspectRatios {
const val WIDE = 16f / 9f
const val FOUR_THREE = 4f / 3f
const val TALL = 2f / 3f
}
@Preview( @Preview(
device = "spec:parent=tv_1080p", device = "spec:parent=tv_1080p",
backgroundColor = 0xFF383535, backgroundColor = 0xFF383535,

View file

@ -34,6 +34,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -53,7 +54,7 @@ fun BannerCard(
favorite: Boolean = false, favorite: Boolean = false,
playPercent: Double = 0.0, playPercent: Double = 0.0,
cardHeight: Dp = 140.dp * .85f, cardHeight: Dp = 140.dp * .85f,
aspectRatio: Float = 16f / 9, aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
) { ) {
var imageError by remember { mutableStateOf(false) } var imageError by remember { mutableStateOf(false) }

View file

@ -30,9 +30,9 @@ fun ChapterCard(
position: Duration, position: Duration,
imageUrl: String?, imageUrl: String?,
onClick: () -> Unit, onClick: () -> Unit,
aspectRatio: Float,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
cardHeight: Dp = 140.dp * .85f, cardHeight: Dp = 140.dp * .85f,
aspectRatio: Float = 16f / 9,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
) { ) {

View file

@ -21,6 +21,7 @@ import com.github.damontecres.wholphin.data.model.Chapter
@Composable @Composable
fun ChapterRow( fun ChapterRow(
chapters: List<Chapter>, chapters: List<Chapter>,
aspectRatio: Float,
onClick: (Chapter) -> Unit, onClick: (Chapter) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLongClick: ((Chapter) -> Unit)? = null, onLongClick: ((Chapter) -> Unit)? = null,
@ -50,6 +51,7 @@ fun ChapterRow(
position = item.position, position = item.position,
imageUrl = item.imageUrl, imageUrl = item.imageUrl,
onClick = { onClick(item) }, onClick = { onClick(item) },
aspectRatio = aspectRatio,
modifier = Modifier, modifier = Modifier,
onLongClick = onLongClick?.let { { it(item) } }, onLongClick = onLongClick?.let { { it(item) } },
) )

View file

@ -30,6 +30,7 @@ import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisode
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -63,7 +64,7 @@ fun EpisodeCard(
} else { } else {
focusedAfterDelay = false focusedAfterDelay = false
} }
val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f) val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL
val width = imageHeight * aspectRatio val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio) val height = imageWidth * (1f / aspectRatio)
Column( Column(

View file

@ -25,6 +25,7 @@ import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -38,7 +39,7 @@ fun GridCard(
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
imageAspectRatio: Float = 2f / 3f, imageAspectRatio: Float = AspectRatios.TALL,
) { ) {
val dto = item?.data val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()

View file

@ -22,6 +22,7 @@ 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
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.util.FocusPair import com.github.damontecres.wholphin.util.FocusPair
@Composable @Composable
@ -112,7 +113,8 @@ fun BannerItemRow(
name = title, name = title,
imageUrl = item?.imageUrl, imageUrl = item?.imageUrl,
aspectRatio = aspectRatio =
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() ?: (16f / 9), aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat()
?: AspectRatios.WIDE,
cornerText = item?.data?.indexNumber?.let { "E$it" }, cornerText = item?.data?.indexNumber?.let { "E$it" },
played = item?.data?.userData?.played ?: false, played = item?.data?.userData?.played ?: false,
playPercent = item?.data?.userData?.playedPercentage ?: 0.0, playPercent = item?.data?.userData?.playedPercentage ?: 0.0,

View file

@ -22,6 +22,7 @@ import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -81,7 +82,7 @@ fun PersonCard(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(2f / 3f), // TODO, .aspectRatio(AspectRatios.TALL), // TODO,
) )
} }
Column( Column(

View file

@ -26,6 +26,7 @@ import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -42,7 +43,7 @@ fun SeasonCard(
imageWidth: Dp = Dp.Unspecified, imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false, showImageOverlay: Boolean = false,
aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f), aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL,
) = SeasonCard( ) = SeasonCard(
title = item?.title, title = item?.title,
subtitle = item?.subtitle, subtitle = item?.subtitle,
@ -82,7 +83,7 @@ fun SeasonCard(
imageWidth: Dp = Dp.Unspecified, imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false, showImageOverlay: Boolean = false,
aspectRatio: Float = (2f / 3f), aspectRatio: Float = AspectRatios.TALL,
) { ) {
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
@ -102,7 +103,6 @@ fun SeasonCard(
} else { } else {
focusedAfterDelay = false focusedAfterDelay = false
} }
// val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f)
val width = imageHeight * aspectRatio val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio) val height = imageWidth * (1f / aspectRatio)
Column( Column(

View file

@ -12,7 +12,7 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
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.setValueOnMain
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
@ -26,6 +26,8 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -95,8 +97,8 @@ class RecommendedMovieViewModel
), ),
) )
withContext(Dispatchers.Main) { if (resumeItems.isNotEmpty()) {
loading.value = LoadingState.Success loading.setValueOnMain(LoadingState.Success)
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Exception fetching movie recommendations") Timber.e(ex, "Exception fetching movie recommendations")
@ -159,9 +161,9 @@ class RecommendedMovieViewModel
R.string.suggestions to suggestedItems, R.string.suggestions to suggestedItems,
R.string.top_unwatched to unwatchedTopRatedItems, R.string.top_unwatched to unwatchedTopRatedItems,
) )
rows
rows.forEachIndexed { index, (title, pager) -> .mapIndexed { index, (title, pager) ->
viewModelScope.launchIO { viewModelScope.async(Dispatchers.IO + ExceptionHandler()) {
val title = context.getString(title) val title = context.getString(title)
val result = val result =
try { try {
@ -175,8 +177,16 @@ class RecommendedMovieViewModel
HomeRowLoadingState.Error(title, null, ex) HomeRowLoadingState.Error(title, null, ex)
} }
update(index + 1, result) update(index + 1, result)
if (result is HomeRowLoadingState.Success && result.items.isNotEmpty() &&
(loading.value == LoadingState.Loading || loading.value == LoadingState.Pending)
) {
loading.setValueOnMain(LoadingState.Success)
} }
} }
}.awaitAll()
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
loading.setValueOnMain(LoadingState.Success)
}
} }
} }

View file

@ -12,7 +12,7 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
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.setValueOnMain
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
@ -27,6 +27,8 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -123,8 +125,8 @@ class RecommendedTvShowViewModel
), ),
) )
withContext(Dispatchers.Main) { if (resumeItems.isNotEmpty() || nextUpItems.isNotEmpty()) {
loading.value = LoadingState.Success loading.setValueOnMain(LoadingState.Success)
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Exception fetching tv recommendations") Timber.e(ex, "Exception fetching tv recommendations")
@ -188,20 +190,32 @@ class RecommendedTvShowViewModel
R.string.top_unwatched to unwatchedTopRatedItems, R.string.top_unwatched to unwatchedTopRatedItems,
) )
rows.forEachIndexed { index, (title, pager) -> rows
viewModelScope.launchIO { .mapIndexed { index, (title, pager) ->
viewModelScope.async(Dispatchers.IO + ExceptionHandler()) {
val title = context.getString(title) val title = context.getString(title)
val result = val result =
try { try {
pager.init() pager.init()
if (pager.isNotEmpty()) {
pager.getBlocking(0)
}
HomeRowLoadingState.Success(title, pager) HomeRowLoadingState.Success(title, pager)
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error fetching %s", title) Timber.e(ex, "Error fetching %s", title)
HomeRowLoadingState.Error(title, null, ex) HomeRowLoadingState.Error(title, null, ex)
} }
update(index + 2, result) update(index + 2, result)
if (result is HomeRowLoadingState.Success && result.items.isNotEmpty() &&
(loading.value == LoadingState.Loading || loading.value == LoadingState.Pending)
) {
loading.setValueOnMain(LoadingState.Success)
} }
} }
}.awaitAll()
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
loading.setValueOnMain(LoadingState.Success)
}
} }
} }

View file

@ -51,9 +51,11 @@ import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ChapterRow import com.github.damontecres.wholphin.ui.cards.ChapterRow
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
@ -486,6 +488,7 @@ fun MovieDetailsContent(
item { item {
ChapterRow( ChapterRow(
chapters = chapters, chapters = chapters,
aspectRatio = movie.data.aspectRatioFloat ?: AspectRatios.WIDE,
onClick = { onClick = {
position = CHAPTER_ROW position = CHAPTER_ROW
playOnClick.invoke(it.position) playOnClick.invoke(it.position)

View file

@ -42,8 +42,8 @@ import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -216,10 +216,8 @@ fun SeriesOverviewContent(
?.data ?.data
?.primaryImageAspectRatio ?.primaryImageAspectRatio
?.toFloat() ?.toFloat()
?.coerceAtLeast( ?.coerceAtLeast(AspectRatios.FOUR_THREE)
episode.data.aspectRatioFloat ?: (4f / 3f), ?: (AspectRatios.WIDE),
)
?: (16f / 9), // TODO some episode images don't match the file's aspect ratio
cornerText = cornerText, cornerText = cornerText,
played = episode?.data?.userData?.played ?: false, played = episode?.data?.userData?.played ?: false,
playPercent = playPercent =

View file

@ -46,6 +46,7 @@ import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
@ -197,8 +198,20 @@ fun HomePageContent(
loadingState: LoadingState? = null, loadingState: LoadingState? = null,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val firstRow =
remember {
homeRows
.indexOfFirst {
when (it) {
is HomeRowLoadingState.Error -> false
is HomeRowLoadingState.Loading -> true
is HomeRowLoadingState.Pending -> true
is HomeRowLoadingState.Success -> it.items.isNotEmpty()
}
}.coerceAtLeast(0)
}
var position by rememberSaveable(stateSaver = RowColumnSaver) { var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(0, 0)) mutableStateOf(RowColumn(firstRow, 0))
} }
var focusedItem = var focusedItem =
position.let { position.let {
@ -208,11 +221,19 @@ fun HomePageContent(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val positionFocusRequester = remember { FocusRequester() } val positionFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { var focused by remember { mutableStateOf(false) }
LaunchedEffect(homeRows) {
if (!focused) {
homeRows
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
.takeIf { it >= 0 }
?.let {
positionFocusRequester.tryRequestFocus() positionFocusRequester.tryRequestFocus()
// Hacky, but mostly works
delay(50) delay(50)
listState.animateScrollToItem(position.row) listState.animateScrollToItem(position.row)
focused = true
}
}
} }
LaunchedEffect(position) { LaunchedEffect(position) {
listState.animateScrollToItem(position.row) listState.animateScrollToItem(position.row)
@ -333,11 +354,10 @@ fun HomePageContent(
.fillMaxWidth() .fillMaxWidth()
.animateItem(), .animateItem(),
cardContent = { index, item, cardModifier, onClick, onLongClick -> cardContent = { index, item, cardModifier, onClick, onLongClick ->
// TODO better aspect ration handling?
BannerCard( BannerCard(
name = item?.data?.seriesName ?: item?.name, name = item?.data?.seriesName ?: item?.name,
imageUrl = item?.imageUrl, imageUrl = item?.imageUrl,
aspectRatio = (2f / 3f), aspectRatio = AspectRatios.TALL,
cornerText = cornerText =
item?.data?.indexNumber?.let { "E$it" } item?.data?.indexNumber?.let { "E$it" }
?: item?.data?.childCount?.let { if (it > 0) it.toString() else null }, ?: item?.data?.childCount?.let { if (it > 0) it.toString() else null },

View file

@ -45,6 +45,7 @@ import coil3.compose.AsyncImagePreviewHandler
import coil3.compose.LocalAsyncImagePreviewHandler import coil3.compose.LocalAsyncImagePreviewHandler
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -62,7 +63,7 @@ fun NextUpEpisode(
onClick: () -> Unit, onClick: () -> Unit,
timeLeft: Duration?, timeLeft: Duration?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
aspectRatio: Float = 16f / 9, aspectRatio: Float = AspectRatios.WIDE,
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -202,7 +203,7 @@ private fun NextUpEpisodePreview() {
"But it just keeps going and going.", "But it just keeps going and going.",
imageUrl = "", imageUrl = "",
onClick = {}, onClick = {},
aspectRatio = 4f / 3, aspectRatio = AspectRatios.FOUR_THREE,
timeLeft = 30.seconds, timeLeft = 30.seconds,
modifier = modifier =
Modifier Modifier

View file

@ -50,7 +50,9 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ChapterCard import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
@ -75,6 +77,7 @@ private val subtitleTextSize = 18.sp
*/ */
@Composable @Composable
fun PlaybackOverlay( fun PlaybackOverlay(
item: BaseItem?,
title: String?, title: String?,
subtitleStreams: List<SubtitleStream>, subtitleStreams: List<SubtitleStream>,
chapters: List<Chapter>, chapters: List<Chapter>,
@ -236,6 +239,7 @@ fun PlaybackOverlay(
name = chapter.name, name = chapter.name,
position = chapter.position, position = chapter.position,
imageUrl = chapter.imageUrl, imageUrl = chapter.imageUrl,
aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE,
onClick = { onClick = {
playerControls.seekTo(chapter.position.inWholeMilliseconds) playerControls.seekTo(chapter.position.inWholeMilliseconds)
controllerViewState.hideControls() controllerViewState.hideControls()

View file

@ -69,6 +69,7 @@ import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
@ -332,6 +333,7 @@ fun PlaybackPage(
.padding(WindowInsets.systemBars.asPaddingValues()) .padding(WindowInsets.systemBars.asPaddingValues())
.fillMaxSize() .fillMaxSize()
.background(Color.Transparent), .background(Color.Transparent),
item = currentPlayback?.item,
title = title, title = title,
subtitle = subtitle, subtitle = subtitle,
subtitleStreams = subtitleStreams, subtitleStreams = subtitleStreams,
@ -488,7 +490,9 @@ fun PlaybackPage(
).joinToString(" - "), ).joinToString(" - "),
description = it.data.overview, description = it.data.overview,
imageUrl = it.imageUrl, imageUrl = it.imageUrl,
aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: (16f / 9), aspectRatio =
it.data.primaryImageAspectRatio?.toFloat()
?: AspectRatios.WIDE,
onClick = { onClick = {
viewModel.reportInteraction() viewModel.reportInteraction()
viewModel.playNextUp() viewModel.playNextUp()

View file

@ -75,7 +75,6 @@ import org.jellyfin.sdk.api.client.extensions.trickplayApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentDto
@ -142,7 +141,7 @@ class PlaybackViewModel
private lateinit var preferences: UserPreferences private lateinit var preferences: UserPreferences
private lateinit var deviceProfile: DeviceProfile private lateinit var deviceProfile: DeviceProfile
private lateinit var itemId: UUID private lateinit var itemId: UUID
private lateinit var dto: BaseItemDto private lateinit var item: BaseItem
private var activityListener: TrackActivityPlaybackListener? = null private var activityListener: TrackActivityPlaybackListener? = null
val nextUp = MutableLiveData<BaseItem?>() val nextUp = MutableLiveData<BaseItem?>()
@ -204,10 +203,11 @@ class PlaybackViewModel
} else { } else {
queriedItem queriedItem
} }
val item = BaseItem.from(base, api)
val played = val played =
play( play(
base, item,
destination.positionMs, destination.positionMs,
destination.itemPlayback, destination.itemPlayback,
destination.forceTranscoding, destination.forceTranscoding,
@ -228,25 +228,25 @@ class PlaybackViewModel
} }
private suspend fun play( private suspend fun play(
base: BaseItemDto, item: BaseItem,
positionMs: Long, positionMs: Long,
itemPlayback: ItemPlayback? = null, itemPlayback: ItemPlayback? = null,
forceTranscoding: Boolean = false, forceTranscoding: Boolean = false,
): Boolean = ): Boolean =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Timber.i("Playing ${base.id}") Timber.i("Playing ${item.id}")
autoSkippedSegments.clear() autoSkippedSegments.clear()
if (base.type !in supportItemKinds) { if (item.type !in supportItemKinds) {
showToast( showToast(
context, context,
"Unsupported type '${base.type}', skipping...", "Unsupported type '${item.type}', skipping...",
Toast.LENGTH_SHORT, Toast.LENGTH_SHORT,
) )
return@withContext false return@withContext false
} }
val isLiveTv = base.type == BaseItemKind.TV_CHANNEL val isLiveTv = item.type == BaseItemKind.TV_CHANNEL
dto = base val base = item.data
val title = val title =
if (base.type == BaseItemKind.EPISODE) { if (base.type == BaseItemKind.EPISODE) {
base.seriesName base.seriesName
@ -358,7 +358,7 @@ class PlaybackViewModel
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
changeStreams( changeStreams(
base, item,
itemPlaybackToUse, itemPlaybackToUse,
audioIndex, audioIndex,
subtitleIndex, subtitleIndex,
@ -379,7 +379,7 @@ class PlaybackViewModel
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private suspend fun changeStreams( private suspend fun changeStreams(
item: BaseItemDto, item: BaseItem,
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!, currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
@ -392,7 +392,7 @@ class PlaybackViewModel
val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend
val currentPlayback = this@PlaybackViewModel.currentPlayback.value val currentPlayback = this@PlaybackViewModel.currentPlayback.value
if (currentPlayback != null && currentPlayback.itemId == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) {
// If direct playing, can try to switch tracks without playback restarting // If direct playing, can try to switch tracks without playback restarting
// Except for external subtitles // Except for external subtitles
// TODO there's probably no reason why we can't add external subtitles? // TODO there's probably no reason why we can't add external subtitles?
@ -549,9 +549,9 @@ class PlaybackViewModel
val playback = val playback =
CurrentPlayback( CurrentPlayback(
itemId = item.id, item = item,
backend = preferences.appPreferences.playbackPreferences.playerBackend,
tracks = listOf(), tracks = listOf(),
backend = preferences.appPreferences.playbackPreferences.playerBackend,
playMethod = transcodeType, playMethod = transcodeType,
playSessionId = response.playSessionId, playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId, liveStreamId = source.liveStreamId,
@ -623,7 +623,7 @@ class PlaybackViewModel
} }
} }
val trickPlayInfo = val trickPlayInfo =
item.trickplay item.data.trickplay
?.get(source.id) ?.get(source.id)
?.values ?.values
?.firstOrNull() ?.firstOrNull()
@ -637,7 +637,7 @@ class PlaybackViewModel
fun changeAudioStream(index: Int) { fun changeAudioStream(index: Int) {
viewModelScope.launchIO { viewModelScope.launchIO {
changeStreams( changeStreams(
dto, item,
currentItemPlayback.value!!, currentItemPlayback.value!!,
index, index,
currentItemPlayback.value?.subtitleIndex, currentItemPlayback.value?.subtitleIndex,
@ -650,7 +650,7 @@ class PlaybackViewModel
fun changeSubtitleStream(index: Int?): Job = fun changeSubtitleStream(index: Int?): Job =
viewModelScope.launchIO { viewModelScope.launchIO {
changeStreams( changeStreams(
dto, item,
currentItemPlayback.value!!, currentItemPlayback.value!!,
currentItemPlayback.value?.audioIndex, currentItemPlayback.value?.audioIndex,
index, index,
@ -660,7 +660,7 @@ class PlaybackViewModel
} }
fun getTrickplayUrl(index: Int): String? { fun getTrickplayUrl(index: Int): String? {
val itemId = dto.id val itemId = item.id
val mediaSourceId = currentItemPlayback.value?.sourceId val mediaSourceId = currentItemPlayback.value?.sourceId
val trickPlayInfo = trickplay.value ?: return null val trickPlayInfo = trickplay.value ?: return null
return api.trickplayApi.getTrickplayTileImageUrl( return api.trickplayApi.getTrickplayTileImageUrl(
@ -792,7 +792,7 @@ class PlaybackViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
cancelUpNextEpisode() cancelUpNextEpisode()
val item = it.getAndAdvance() val item = it.getAndAdvance()
val played = play(item.data, 0) val played = play(item, 0)
if (!played) { if (!played) {
playNextUp() playNextUp()
} }
@ -807,7 +807,7 @@ class PlaybackViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
cancelUpNextEpisode() cancelUpNextEpisode()
val item = it.getPreviousAndReverse() val item = it.getPreviousAndReverse()
val played = play(item.data, 0) val played = play(item, 0)
if (!played) { if (!played) {
playPrevious() playPrevious()
} }
@ -825,7 +825,7 @@ class PlaybackViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
val toPlay = playlist.advanceTo(item.id) val toPlay = playlist.advanceTo(item.id)
if (toPlay != null) { if (toPlay != null) {
val played = play(toPlay.data, 0) val played = play(toPlay, 0)
if (!played) { if (!played) {
playNextUp() playNextUp()
} }
@ -859,7 +859,7 @@ class PlaybackViewModel
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> { PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding") Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
changeStreams( changeStreams(
dto, item,
currentItemPlayback.value!!, currentItemPlayback.value!!,
currentItemPlayback.value?.audioIndex, currentItemPlayback.value?.audioIndex,
currentItemPlayback.value?.subtitleIndex, currentItemPlayback.value?.subtitleIndex,
@ -978,8 +978,12 @@ class PlaybackViewModel
while (maxAttempts > 0 && subtitleCount == newCount) { while (maxAttempts > 0 && subtitleCount == newCount) {
maxAttempts-- maxAttempts--
delay(1500) delay(1500)
dto = api.userLibraryApi.getItem(itemId = dto.id).content item =
val mediaSource = chooseSource(dto, it) BaseItem.from(
api.userLibraryApi.getItem(itemId = item.id).content,
api,
)
val mediaSource = chooseSource(item.data, it)
if (mediaSource == null) { if (mediaSource == null) {
// This shouldn't happen, but just in case // This shouldn't happen, but just in case
showToast( showToast(
@ -1036,7 +1040,7 @@ class PlaybackViewModel
audioIndex += 1 audioIndex += 1
} }
changeStreams( changeStreams(
dto, item,
currentItemPlayback.value!!, currentItemPlayback.value!!,
audioIndex, audioIndex,
newStream.index, newStream.index,
@ -1066,9 +1070,9 @@ class PlaybackViewModel
} }
data class CurrentPlayback( data class CurrentPlayback(
val itemId: UUID, val item: BaseItem,
val backend: PlayerBackend,
val tracks: List<TrackSupport>, val tracks: List<TrackSupport>,
val backend: PlayerBackend,
val playMethod: PlayMethod, val playMethod: PlayMethod,
val playSessionId: String?, val playSessionId: String?,
val liveStreamId: String?, val liveStreamId: String?,

View file

@ -53,6 +53,7 @@ fun SwitchServerContent(
var showAddServer by remember { mutableStateOf(false) } var showAddServer by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init()
viewModel.discoverServers() viewModel.discoverServers()
} }

View file

@ -52,10 +52,6 @@ class SwitchServerViewModel
addServerState.value = LoadingState.Pending addServerState.value = LoadingState.Pending
} }
init {
init()
}
fun init() { fun init() {
viewModelScope.launchIO { viewModelScope.launchIO {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {

View file

@ -58,6 +58,9 @@ fun SwitchUserContent(
), ),
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.init()
}
// val currentServer by viewModel.serverRepository.currentServer.observeAsState() // val currentServer by viewModel.serverRepository.currentServer.observeAsState()
val currentUser by viewModel.serverRepository.currentUser.observeAsState() val currentUser by viewModel.serverRepository.currentUser.observeAsState()

View file

@ -48,12 +48,6 @@ class SwitchUserViewModel
fun create(server: JellyfinServer): SwitchUserViewModel fun create(server: JellyfinServer): SwitchUserViewModel
} }
init {
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
serverRepository.switchServerOrUser()
}
}
val serverQuickConnect = MutableLiveData<Boolean>(false) val serverQuickConnect = MutableLiveData<Boolean>(false)
val users = MutableLiveData<List<JellyfinUser>>(listOf()) val users = MutableLiveData<List<JellyfinUser>>(listOf())
@ -78,6 +72,9 @@ class SwitchUserViewModel
} }
fun init() { fun init() {
viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) {
serverRepository.switchServerOrUser()
}
quickConnectJob?.cancel() quickConnectJob?.cancel()
viewModelScope.launchIO { viewModelScope.launchIO {
users.setValueOnMain(listOf()) users.setValueOnMain(listOf())