diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2948e841..df0c5200 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index 566d0d20..22ad8b1b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -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 = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/Trailer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/Trailer.kt new file mode 100644 index 00000000..d0637d9b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/Trailer.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 3e019c69..8add8b02 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -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>(listOf()) val chapters = MutableLiveData>(listOf()) val chosenStreams = MutableLiveData(null) - val trailers = MutableLiveData>(listOf()) + val trailers = MutableLiveData>(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, chapters: List, - trailers: List, + trailers: List, 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, + 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, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index 95a1aaf4..c9ff33aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -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\\*\\*.*"), ""), ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cbe514f3..5fca3a60 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.10.00" compose-runtime = "1.9.3" +multiplatformMarkdownRenderer = "0.37.0" programguide = "1.6.0" timber = "5.0.1" tvFoundation = "1.0.0-alpha12" @@ -57,6 +58,8 @@ androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "d desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } +multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" }