Merge branch 'main' into fea/live-tv

This commit is contained in:
Damontecres 2025-10-21 19:53:11 -04:00
commit 7f95dc44ac
No known key found for this signature in database
6 changed files with 225 additions and 67 deletions

View file

@ -228,6 +228,8 @@ dependencies {
implementation(libs.timber)
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.multiplatform.markdown.renderer)
implementation(libs.multiplatform.markdown.renderer.m3)
implementation(libs.programguide)
androidTestImplementation(platform(libs.androidx.compose.bom))

View file

@ -43,8 +43,47 @@ fun SeasonCard(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false,
aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f),
) = SeasonCard(
title = item?.title,
subtitle = item?.subtitle,
name = item?.name,
imageUrl = item?.imageUrl,
isFavorite = item?.data?.userData?.isFavorite ?: false,
isPlayed = item?.data?.userData?.played ?: false,
unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0,
playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
imageHeight = imageHeight,
imageWidth = imageWidth,
interactionSource = interactionSource,
showImageOverlay = showImageOverlay,
aspectRatio = aspectRatio,
)
/**
* A Card for a TV Show Season, but can generically show most items
*/
@Composable
fun SeasonCard(
title: String?,
subtitle: String?,
name: String?,
imageUrl: String?,
isFavorite: Boolean,
isPlayed: Boolean,
unplayedItemCount: Int,
playedPercentage: Double,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false,
aspectRatio: Float = (2f / 3f),
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
@ -89,13 +128,13 @@ fun SeasonCard(
.fillMaxSize(),
) {
ItemCardImage(
imageUrl = item?.imageUrl,
name = item?.name,
imageUrl = imageUrl,
name = name,
showOverlay = showImageOverlay,
favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
watchedPercent = dto?.userData?.playedPercentage,
favorite = isFavorite,
watched = isPlayed,
unwatchedCount = unplayedItemCount,
watchedPercent = playedPercentage,
useFallbackText = false,
modifier =
Modifier
@ -111,7 +150,7 @@ fun SeasonCard(
.fillMaxWidth(),
) {
Text(
text = item?.title ?: "",
text = title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
@ -121,7 +160,7 @@ fun SeasonCard(
.enableMarquee(focusedAfterDelay),
)
Text(
text = item?.subtitle ?: "",
text = subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =

View file

@ -0,0 +1,19 @@
package com.github.damontecres.wholphin.ui.data
import com.github.damontecres.wholphin.data.model.BaseItem
sealed interface Trailer {
val name: String
}
data class LocalTrailer(
val baseItem: BaseItem,
) : Trailer {
override val name: String
get() = baseItem.name ?: ""
}
data class RemoteTrailer(
override val name: String,
val url: String,
) : Trailer

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.movie
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -9,6 +10,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
@ -25,16 +29,20 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
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 coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
@ -47,7 +55,6 @@ import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ChapterRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.DialogParams
@ -59,6 +66,9 @@ import com.github.damontecres.wholphin.ui.components.chooseStream
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.LocalTrailer
import com.github.damontecres.wholphin.ui.data.RemoteTrailer
import com.github.damontecres.wholphin.ui.data.Trailer
import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -96,7 +106,7 @@ class MovieViewModel
val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf())
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
val trailers = MutableLiveData<List<BaseItem>>(listOf())
val trailers = MutableLiveData<List<Trailer>>(listOf())
override fun init(
itemId: UUID,
@ -111,15 +121,31 @@ class MovieViewModel
withContext(Dispatchers.Main) {
chosenStreams.value = result
}
val remoteTrailers =
item.data.remoteTrailers
?.mapNotNull { t ->
t.url?.let { url ->
val name =
t.name
// TODO would be nice to clean up the trailer name
// ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ")
?: "Trailer"
RemoteTrailer(name, url)
}
}.orEmpty()
.sortedBy { it.name }
val localTrailerCount = item.data.localTrailerCount ?: 0
if (localTrailerCount > 0) {
val trailers =
val localTrailers =
if (localTrailerCount > 0) {
api.userLibraryApi.getLocalTrailers(itemId).content.map {
BaseItem.from(it, api)
LocalTrailer(BaseItem.from(it, api))
}
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = trailers
} else {
listOf()
}
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
}
}
withContext(Dispatchers.Main) {
@ -192,6 +218,7 @@ fun MovieDetails(
modifier: Modifier = Modifier,
viewModel: MovieViewModel = hiltViewModel(),
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item)
}
@ -291,13 +318,21 @@ fun MovieDetails(
viewModel.setWatched((movie.data.userData?.played ?: false).not())
},
trailerOnClick = { trailer ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = trailer.id,
item = trailer,
positionMs = 0L,
),
)
when (trailer) {
is LocalTrailer ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = trailer.baseItem.id,
item = trailer.baseItem,
positionMs = 0L,
),
)
is RemoteTrailer -> {
val intent = Intent(Intent.ACTION_VIEW, trailer.url.toUri())
context.startActivity(intent)
}
}
},
modifier = modifier,
)
@ -339,9 +374,9 @@ fun MovieDetailsContent(
chosenStreams: ChosenStreams?,
people: List<Person>,
chapters: List<Chapter>,
trailers: List<BaseItem>,
trailers: List<Trailer>,
playOnClick: (Duration) -> Unit,
trailerOnClick: (BaseItem) -> Unit,
trailerOnClick: (Trailer) -> Unit,
overviewOnClick: () -> Unit,
watchOnClick: () -> Unit,
moreOnClick: () -> Unit,
@ -440,22 +475,10 @@ fun MovieDetailsContent(
}
if (trailers.isNotEmpty()) {
item {
ItemRow(
title = stringResource(R.string.trailers),
items = trailers,
onClickItem = trailerOnClick,
onLongClickItem = {},
cardContent = @Composable { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
imageWidth = Dp.Unspecified,
showImageOverlay = false,
modifier = mod,
)
},
TrailerRow(
trailers = trailers,
onClickTrailer = trailerOnClick,
modifier = Modifier.fillMaxWidth(),
)
}
}
@ -472,3 +495,77 @@ fun MovieDetailsContent(
}
}
}
@Composable
fun TrailerRow(
trailers: List<Trailer>,
onClickTrailer: (Trailer) -> Unit,
modifier: Modifier = Modifier,
) {
val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = stringResource(R.string.trailers),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(firstFocus),
) {
itemsIndexed(trailers) { index, item ->
val cardModifier =
if (index == 0) {
Modifier.focusRequester(firstFocus)
} else {
Modifier
}
when (item) {
is LocalTrailer ->
SeasonCard(
item = item.baseItem,
onClick = { onClickTrailer.invoke(item) },
onLongClick = {},
imageHeight = Cards.height2x3,
imageWidth = Dp.Unspecified,
showImageOverlay = false,
modifier = cardModifier,
)
is RemoteTrailer -> {
val subtitle =
when (item.url.toUri().host) {
"youtube.com", "www.youtube.com" -> "YouTube"
else -> null
}
SeasonCard(
title = item.name,
subtitle = subtitle,
name = item.name,
imageUrl = null,
isFavorite = false,
isPlayed = false,
unplayedItemCount = 0,
playedPercentage = 0.0,
onClick = { onClickTrailer.invoke(item) },
onLongClick = {},
modifier = cardModifier,
showImageOverlay = false,
imageHeight = Cards.height2x3,
imageWidth = Dp.Unspecified,
)
}
}
}
}
}
}

View file

@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Release
import com.github.damontecres.wholphin.util.UpdateChecker
import com.github.damontecres.wholphin.util.Version
import com.mikepenz.markdown.m3.Markdown
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -120,27 +121,21 @@ fun InstallUpdatePage(
LoadingState.Success ->
release?.let {
if (it.version.isGreaterThan(viewModel.currentVersion)) {
InstallUpdatePageContent(
currentVersion = viewModel.currentVersion,
release = it,
onInstallRelease = {
if (!permissions) {
launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
viewModel.installRelease(it)
}
},
onCancel = {
viewModel.navigationManager.goBack()
},
modifier = modifier,
)
} else {
Text(
text = "No update available",
)
}
InstallUpdatePageContent(
currentVersion = viewModel.currentVersion,
release = it,
onInstallRelease = {
if (!permissions) {
launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
viewModel.installRelease(it)
}
},
onCancel = {
viewModel.navigationManager.goBack()
},
modifier = modifier,
)
}
}
}
@ -202,11 +197,14 @@ fun InstallUpdatePageContent(
},
) {
item {
Text(
// TODO render markdown
text = release.notes.joinToString("\n\n") + (release.body ?: ""),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
Markdown(
(release.notes.joinToString("\n\n") + (release.body ?: ""))
.replace(
Regex("https://github.com/damontecres/\\w+/pull/(\\d+)"),
"#$1",
)
// Remove the last line for full changelog since its just a link
.replace(Regex("\\*\\*Full Changelog\\*\\*.*"), ""),
)
}
}