mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Support remote movie trailers (#50)
Show remote trailers on movie details page. They play in a separate compatible app such as YouTube. Local trailers are shown first
This commit is contained in:
parent
2770ae1e4a
commit
6344cc77bf
3 changed files with 196 additions and 41 deletions
|
|
@ -43,8 +43,47 @@ fun SeasonCard(
|
||||||
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() ?: (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 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)
|
||||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||||
|
|
@ -89,13 +128,13 @@ fun SeasonCard(
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
ItemCardImage(
|
ItemCardImage(
|
||||||
imageUrl = item?.imageUrl,
|
imageUrl = imageUrl,
|
||||||
name = item?.name,
|
name = name,
|
||||||
showOverlay = showImageOverlay,
|
showOverlay = showImageOverlay,
|
||||||
favorite = dto?.userData?.isFavorite ?: false,
|
favorite = isFavorite,
|
||||||
watched = dto?.userData?.played ?: false,
|
watched = isPlayed,
|
||||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
unwatchedCount = unplayedItemCount,
|
||||||
watchedPercent = dto?.userData?.playedPercentage,
|
watchedPercent = playedPercentage,
|
||||||
useFallbackText = false,
|
useFallbackText = false,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|
@ -111,7 +150,7 @@ fun SeasonCard(
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = item?.title ?: "",
|
text = title ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier =
|
modifier =
|
||||||
|
|
@ -121,7 +160,7 @@ fun SeasonCard(
|
||||||
.enableMarquee(focusedAfterDelay),
|
.enableMarquee(focusedAfterDelay),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item?.subtitle ?: "",
|
text = subtitle ?: "",
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier =
|
modifier =
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.detail.movie
|
package com.github.damontecres.wholphin.ui.detail.movie
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
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
|
||||||
|
|
@ -9,6 +10,9 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
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.draw.drawWithContent
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
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.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
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.data.ChosenStreams
|
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.preferences.UserPreferences
|
||||||
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.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
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.components.chooseVersionParams
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
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.LoadingItemViewModel
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
|
@ -96,7 +106,7 @@ class MovieViewModel
|
||||||
val people = MutableLiveData<List<Person>>(listOf())
|
val people = MutableLiveData<List<Person>>(listOf())
|
||||||
val chapters = MutableLiveData<List<Chapter>>(listOf())
|
val chapters = MutableLiveData<List<Chapter>>(listOf())
|
||||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||||
val trailers = MutableLiveData<List<BaseItem>>(listOf())
|
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||||
|
|
||||||
override fun init(
|
override fun init(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
|
|
@ -111,15 +121,31 @@ class MovieViewModel
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
chosenStreams.value = result
|
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
|
val localTrailerCount = item.data.localTrailerCount ?: 0
|
||||||
if (localTrailerCount > 0) {
|
val localTrailers =
|
||||||
val trailers =
|
if (localTrailerCount > 0) {
|
||||||
api.userLibraryApi.getLocalTrailers(itemId).content.map {
|
api.userLibraryApi.getLocalTrailers(itemId).content.map {
|
||||||
BaseItem.from(it, api)
|
LocalTrailer(BaseItem.from(it, api))
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
} else {
|
||||||
this@MovieViewModel.trailers.value = trailers
|
listOf()
|
||||||
}
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -192,6 +218,7 @@ fun MovieDetails(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MovieViewModel = hiltViewModel(),
|
viewModel: MovieViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.init(destination.itemId, destination.item)
|
viewModel.init(destination.itemId, destination.item)
|
||||||
}
|
}
|
||||||
|
|
@ -291,13 +318,21 @@ fun MovieDetails(
|
||||||
viewModel.setWatched((movie.data.userData?.played ?: false).not())
|
viewModel.setWatched((movie.data.userData?.played ?: false).not())
|
||||||
},
|
},
|
||||||
trailerOnClick = { trailer ->
|
trailerOnClick = { trailer ->
|
||||||
viewModel.navigationManager.navigateTo(
|
when (trailer) {
|
||||||
Destination.Playback(
|
is LocalTrailer ->
|
||||||
itemId = trailer.id,
|
viewModel.navigationManager.navigateTo(
|
||||||
item = trailer,
|
Destination.Playback(
|
||||||
positionMs = 0L,
|
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,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
@ -339,9 +374,9 @@ fun MovieDetailsContent(
|
||||||
chosenStreams: ChosenStreams?,
|
chosenStreams: ChosenStreams?,
|
||||||
people: List<Person>,
|
people: List<Person>,
|
||||||
chapters: List<Chapter>,
|
chapters: List<Chapter>,
|
||||||
trailers: List<BaseItem>,
|
trailers: List<Trailer>,
|
||||||
playOnClick: (Duration) -> Unit,
|
playOnClick: (Duration) -> Unit,
|
||||||
trailerOnClick: (BaseItem) -> Unit,
|
trailerOnClick: (Trailer) -> Unit,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
watchOnClick: () -> Unit,
|
watchOnClick: () -> Unit,
|
||||||
moreOnClick: () -> Unit,
|
moreOnClick: () -> Unit,
|
||||||
|
|
@ -440,22 +475,10 @@ fun MovieDetailsContent(
|
||||||
}
|
}
|
||||||
if (trailers.isNotEmpty()) {
|
if (trailers.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
ItemRow(
|
TrailerRow(
|
||||||
title = stringResource(R.string.trailers),
|
trailers = trailers,
|
||||||
items = trailers,
|
onClickTrailer = trailerOnClick,
|
||||||
onClickItem = trailerOnClick,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
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,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue