This commit is contained in:
Damontecres 2025-10-15 15:27:30 -04:00
parent aabd8462dc
commit d39ed721d5
No known key found for this signature in database
138 changed files with 738 additions and 738 deletions

View file

@ -0,0 +1,47 @@
package com.github.damontecres.wholphin.ui
import androidx.compose.runtime.Composable
import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.setSingletonImageLoaderFactory
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.cachecontrol.CacheControlCacheStrategy
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import coil3.util.DebugLogger
import okhttp3.OkHttpClient
import kotlin.time.ExperimentalTime
/**
* Configure Coil image loading
*/
@OptIn(ExperimentalTime::class, ExperimentalCoilApi::class)
@Composable
fun CoilConfig(
okHttpClient: OkHttpClient,
debugLogging: Boolean,
) {
setSingletonImageLoaderFactory { ctx ->
ImageLoader
.Builder(ctx)
.memoryCache(MemoryCache.Builder().maxSizePercent(ctx).build())
.diskCache(
DiskCache
.Builder()
.directory(ctx.cacheDir.resolve("coil3_image_cache"))
.maxSizeBytes(100L * 1024 * 1024)
.build(),
).crossfade(false)
.logger(if (debugLogging) DebugLogger() else null)
.components {
add(
OkHttpNetworkFetcherFactory(
cacheStrategy = { CacheControlCacheStrategy() },
callFactory = { okHttpClient },
),
)
}.build()
}
}

View file

@ -0,0 +1,36 @@
package com.github.damontecres.wholphin.ui
import android.graphics.Bitmap
import androidx.core.graphics.scale
import coil3.size.Size
import coil3.size.pxOrElse
import coil3.transform.Transformation
/**
* A Coil [Transformation] that extracts a subimage from a trickplay image
*/
class CoilTrickplayTransformation(
val targetWidth: Int,
val targetHeight: Int,
val numRows: Int,
val numColumns: Int,
val imageIndex: Int,
val index: Int,
) : Transformation() {
private val x: Int = imageIndex % numColumns
private val y: Int = imageIndex / numRows
override val cacheKey: String
get() = "CoilTrickplayTransformation_$index,$x,$y"
override suspend fun transform(
input: Bitmap,
size: Size,
): Bitmap {
val width = input.width / numColumns
val height = input.height / numRows
return Bitmap
.createBitmap(input, x * width, y * height, width, height)
.scale(size.width.pxOrElse { targetWidth }, size.height.pxOrElse { targetHeight })
}
}

View file

@ -0,0 +1,332 @@
package com.github.damontecres.wholphin.ui
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.media.AudioManager
import android.view.KeyEvent
import android.view.WindowManager
import android.widget.Toast
import androidx.compose.foundation.MarqueeAnimationMode
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import coil3.request.ErrorResult
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.RowColumnSaver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
import kotlin.math.min
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
// This file is a dumping ground mostly for extensions
@OptIn(ExperimentalContracts::class)
fun CharSequence?.isNotNullOrBlank(): Boolean {
contract {
returns(true) implies (this@isNotNullOrBlank != null)
}
return !this.isNullOrBlank()
}
inline fun <T> List<T>.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? {
val index = this.indexOfFirst(predicate)
return if (index >= 0) index else null
}
/**
* Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly
*/
fun FocusRequester.tryRequestFocus(): Boolean =
try {
requestFocus()
true
} catch (ex: IllegalStateException) {
Timber.w(ex, "Failed to request focus")
false
}
/**
* Used to apply modifiers conditionally.
*/
fun Modifier.ifElse(
condition: () -> Boolean,
ifTrueModifier: Modifier,
ifFalseModifier: Modifier = Modifier,
): Modifier = then(if (condition()) ifTrueModifier else ifFalseModifier)
/**
* Used to apply modifiers conditionally.
*/
fun Modifier.ifElse(
condition: Boolean,
ifTrueModifier: Modifier,
ifFalseModifier: Modifier = Modifier,
): Modifier = ifElse({ condition }, ifTrueModifier, ifFalseModifier)
fun Modifier.ifElse(
condition: Boolean,
ifTrueModifier: () -> Modifier,
ifFalseModifier: () -> Modifier = { Modifier },
): Modifier = then(if (condition) ifTrueModifier.invoke() else ifFalseModifier.invoke())
/**
* Handles horizontal (Left & Right) D-Pad Keys and consumes the event(s) so that the focus doesn't
* accidentally move to another element.
* */
fun Modifier.handleDPadKeyEvents(
onLeft: (() -> Unit)? = null,
onRight: (() -> Unit)? = null,
onCenter: (() -> Unit)? = null,
triggerOnAction: Int = KeyEvent.ACTION_UP,
) = onPreviewKeyEvent {
fun onActionUp(block: () -> Unit) {
if (it.nativeKeyEvent.action == triggerOnAction) block()
}
when (it.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
onLeft?.apply {
onActionUp(::invoke)
return@onPreviewKeyEvent true
}
}
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
onRight?.apply {
onActionUp(::invoke)
return@onPreviewKeyEvent true
}
}
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
onCenter?.apply {
onActionUp(::invoke)
return@onPreviewKeyEvent true
}
}
}
false
}
/**
* Run a [LaunchedEffect] exactly once even with multiple recompositions.
*
* If the composition is removed from the navigation back stack and "re-added", this will run again
*/
@Composable
fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) {
var hasRun by rememberSaveable { mutableStateOf(false) }
if (!hasRun) {
LaunchedEffect(Unit) {
hasRun = true
runOnceBlock.invoke(this)
}
}
}
fun Modifier.enableMarquee(focused: Boolean) =
if (focused) {
basicMarquee(
initialDelayMillis = 250,
animationMode = MarqueeAnimationMode.Immediately,
velocity = 40.dp,
)
} else {
basicMarquee(animationMode = MarqueeAnimationMode.WhileFocused)
}
@Composable
fun Modifier.playSoundOnFocus(enabled: Boolean): Modifier {
if (!enabled) {
return this
}
val context = LocalContext.current
val audioManager =
remember {
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
}
return onFocusChanged {
if (it.isFocused) {
audioManager.playSoundEffect(AudioManager.FX_FOCUS_NAVIGATION_UP)
}
}
}
fun playOnClickSound(
context: Context,
effectType: Int = AudioManager.FX_KEY_CLICK,
) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.playSoundEffect(effectType)
}
/**
* Rounds a [Duration] to nearest whole minute
*/
val Duration.roundMinutes: Duration
get() = (this + 30.seconds).inWholeMinutes.minutes
/**
* Rounds a [Duration] to nearest whole second
*/
val Duration.roundSeconds: Duration
get() = (this + 30.milliseconds).inWholeSeconds.seconds
/**
* Gets the user's playback position as a [Duration]
*/
val BaseItemDto.timeRemaining: Duration?
get() =
userData?.playbackPositionTicks?.let {
if (it > 0) {
runTimeTicks?.minus(it)?.ticks
} else {
null
}
}
/**
* Seek back the current media item by a [Duration]
*/
fun Player.seekBack(amount: Duration) = seekTo((currentPosition - amount.inWholeMilliseconds).coerceAtLeast(0L))
/**
* Seek forward the current media item by a [Duration]
*/
fun Player.seekForward(amount: Duration) = seekTo((currentPosition + amount.inWholeMilliseconds).coerceAtMost(duration))
/**
* Like [let] but only if the collection is not empty, otherwise returns null
*/
@OptIn(ExperimentalContracts::class)
inline fun <T : Collection<*>, R> T.letNotEmpty(block: (T) -> R): R? {
contract {
callsInPlace(block, InvocationKind.AT_MOST_ONCE)
}
return if (this.isNotEmpty()) block(this) else null
}
// Adapted from https://stackoverflow.com/a/69196765
fun Arrangement.spacedByWithFooter(space: Dp) =
object : Arrangement.Vertical {
override val spacing = space
override fun Density.arrange(
totalSize: Int,
sizes: IntArray,
outPositions: IntArray,
) {
if (sizes.isEmpty()) return
val spacePx = space.roundToPx()
var occupied = 0
sizes.forEachIndexed { index, size ->
if (index == sizes.lastIndex) {
outPositions[index] = totalSize - size
} else {
outPositions[index] = min(occupied, totalSize - size)
}
val lastSpace = min(spacePx, totalSize - outPositions[index] - size)
occupied = outPositions[index] + size + lastSpace
}
}
}
/**
* Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn].
*/
fun Context.findActivity(): Activity? {
if (this is Activity) {
return this
}
var context = this
while (context is ContextWrapper) {
if (context is Activity) return context
context = context.baseContext
}
return null
}
/**
* Keep the screen on for an [Activity]. Often used with [findActivity].
*/
fun Activity.keepScreenOn(keep: Boolean) {
Timber.v("Keep screen on: $keep")
if (keep) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
/**
* Selectively log errors from Coil image loading.
*
* If an HTTP error occurs, the entire stacktrace is not logged.
*/
fun logCoilError(
url: String?,
errorResult: ErrorResult,
) {
if (errorResult.throwable is coil3.network.HttpException) {
Timber.w("Error loading image: %s for %s", errorResult.throwable.localizedMessage, url)
} else {
Timber.e(errorResult.throwable, "Error loading image: %s", url)
}
}
/**
* Convenient way to [rememberSaveable] a [RowColumn]
*/
@Composable
fun rememberPosition(initialPosition: RowColumn = RowColumn(-1, -1)) =
rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(
initialPosition,
)
}
/**
* Show a [Toast]. Ensures it runs on the main thread.
*/
suspend fun showToast(
context: Context,
text: CharSequence,
duration: Int,
) = withContext(Dispatchers.Main) {
Toast.makeText(context, text, duration).show()
}
suspend fun showToast(
context: Context,
text: CharSequence,
) = withContext(Dispatchers.Main) {
Toast.makeText(context, text, Toast.LENGTH_LONG).show()
}

View file

@ -0,0 +1,62 @@
package com.github.damontecres.wholphin.ui
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
import org.jellyfin.sdk.model.api.ItemFields
// This file is for constants used for the UI
val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900))
/**
* Colors not associated with the theme
*/
sealed class AppColors private constructor() {
companion object {
val TransparentBlack25 = Color(0x40000000)
val TransparentBlack50 = Color(0x80000000)
val TransparentBlack75 = Color(0xBF000000)
}
}
const val DEFAULT_PAGE_SIZE = 100
/**
* The default [ItemFields] to fetch for most queries
*/
val DefaultItemFields =
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW,
ItemFields.TRICKPLAY,
ItemFields.SORT_NAME,
ItemFields.CHAPTERS,
)
val DefaultButtonPadding =
PaddingValues(
start = 12.dp / 2,
top = 10.dp / 2,
end = 16.dp / 2,
bottom = 10.dp / 2,
)
object Cards {
val height2x3 = 180.dp
val playedPercentHeight = 6.dp
}
@Preview(
device = "spec:parent=tv_1080p",
backgroundColor = 0xFF383535,
uiMode = UI_MODE_TYPE_TELEVISION,
)
annotation class PreviewTvSpec

View file

@ -0,0 +1,140 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
/**
* Displays an image as a card. If no image is available, the name will be shown instead
*/
@Composable
fun BannerCard(
name: String?,
imageUrl: String?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
cornerText: String? = null,
played: Boolean = false,
playPercent: Double = 0.0,
cardHeight: Dp = 140.dp * .85f,
aspectRatio: Float = 16f / 9,
interactionSource: MutableInteractionSource? = null,
) {
var imageError by remember { mutableStateOf(false) }
Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
if (!imageError && imageUrl.isNotNullOrBlank()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
onError = { imageError = true },
modifier = Modifier.fillMaxSize(),
)
} else {
Text(
text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier =
Modifier
.padding(16.dp)
.align(Alignment.Center),
)
}
if (played || cornerText.isNotNullOrBlank()) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(4.dp),
) {
if (played && (playPercent <= 0 || playPercent >= 100)) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.border.copy(alpha = 1f),
modifier =
Modifier
.size(24.dp),
)
}
if (cornerText.isNotNullOrBlank()) {
Box(
modifier =
Modifier
.background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(25),
),
) {
Text(
text = cornerText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(4.dp),
)
}
}
}
}
if (playPercent > 0 && playPercent < 100) {
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.background(
MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape)
.height(Cards.playedPercentHeight)
.fillMaxWidth((playPercent / 100).toFloat()),
)
}
}
}
}

View file

@ -0,0 +1,72 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.roundSeconds
import kotlin.time.Duration
/**
* Card for a [com.github.damontecres.wholphin.data.model.Chapter]
*/
@Composable
fun ChapterCard(
name: String?,
position: Duration,
imageUrl: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
cardHeight: Dp = 140.dp * .85f,
aspectRatio: Float = 16f / 9,
onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource? = null,
) {
Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(AppColors.TransparentBlack50),
) {
Column(modifier = Modifier.padding(4.dp)) {
name?.let {
Text(
text = it,
)
}
Text(
text = position.roundSeconds.toString(),
)
}
}
}
}
}

View file

@ -0,0 +1,57 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.Chapter
@Composable
fun ChapterRow(
chapters: List<Chapter>,
onClick: (Chapter) -> Unit,
modifier: Modifier = Modifier,
onLongClick: ((Chapter) -> Unit)? = null,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = "Chapters",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
LazyRow(
state = rememberLazyListState(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(16.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth()
.focusRestorer(),
) {
itemsIndexed(chapters) { index, item ->
ChapterCard(
name = item.name,
position = item.position,
imageUrl = item.imageUrl,
onClick = { onClick(item) },
modifier = Modifier,
onLongClick = onLongClick?.let { { it(item) } },
)
}
}
}
}

View file

@ -0,0 +1,143 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.coroutines.delay
@Composable
fun EpisodeCard(
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
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)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f)
val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio)
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
) {
Card(
modifier =
Modifier
.size(imageWidth, imageHeight)
.aspectRatio(aspectRatio),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Box(
modifier =
Modifier
.fillMaxSize(),
) {
ItemCardImage(
imageUrl = item?.imageUrl,
name = item?.name,
showOverlay = false,
favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
watchedPercent = dto?.userData?.playedPercentage,
useFallbackText = false,
modifier =
Modifier
.fillMaxSize(),
)
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.background(AppColors.TransparentBlack50, shape = RoundedCornerShape(8.dp)),
) {
Text(
text = dto?.seasonEpisode ?: "",
modifier = Modifier.padding(4.dp),
)
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(bottom = spaceBelow).fillMaxWidth(),
) {
Text(
text = dto?.seriesName ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = item?.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}

View file

@ -0,0 +1,123 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay
/**
* Card for use in [com.github.damontecres.wholphin.ui.detail.CardGrid]
*/
@Composable
fun GridCard(
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
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)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier,
) {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
ItemCardImage(
imageUrl = item?.imageUrl,
name = item?.name,
// showOverlay = !focusedAfterDelay,
showOverlay = true,
favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
watchedPercent = dto?.userData?.playedPercentage,
useFallbackText = false,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f) // TODO
.background(MaterialTheme.colorScheme.surfaceVariant),
)
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = item?.subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}

View file

@ -0,0 +1,175 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
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.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.logCoilError
@Composable
fun ItemCardImage(
imageUrl: String?,
name: String?,
showOverlay: Boolean,
favorite: Boolean,
watched: Boolean,
unwatchedCount: Int,
watchedPercent: Double?,
modifier: Modifier = Modifier,
useFallbackText: Boolean = true,
) {
var imageError by remember { mutableStateOf(false) }
Box(modifier = modifier) {
if (!imageError && imageUrl.isNotNullOrBlank()) {
AsyncImage(
model = imageUrl,
contentDescription = name,
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
onError = {
logCoilError(imageUrl, it.result)
imageError = true
},
modifier =
Modifier
.fillMaxSize()
.align(Alignment.TopCenter),
)
} else {
// TODO options for overriding fallback
Box(
modifier =
Modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.fillMaxSize()
.align(Alignment.TopCenter),
) {
if (useFallbackText && name.isNotNullOrBlank()) {
Text(
text = name,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
modifier =
Modifier
.padding(8.dp)
.align(Alignment.Center),
)
} else {
Image(
painter = painterResource(id = R.drawable.video_solid),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter =
ColorFilter.tint(
MaterialTheme.colorScheme.onSurfaceVariant,
BlendMode.SrcIn,
),
modifier =
Modifier
.fillMaxSize(.4f)
.align(Alignment.Center),
)
}
}
}
AnimatedVisibility(
visible = showOverlay,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(modifier = Modifier.fillMaxSize()) {
if (favorite) {
Text(
modifier =
Modifier
.align(Alignment.TopStart)
.padding(8.dp),
color = colorResource(android.R.color.holo_red_light),
text = stringResource(R.string.fa_heart),
fontSize = 20.sp,
fontFamily = FontAwesome,
)
}
if (watched && (watchedPercent == null || watchedPercent <= 0.0 || watchedPercent >= 100.0)) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.border.copy(alpha = 1f),
modifier =
Modifier
.align(Alignment.TopEnd)
.size(36.dp)
.padding(4.dp),
)
}
if (unwatchedCount > 0) {
Box(
modifier =
Modifier
.padding(4.dp)
.background(
MaterialTheme.colorScheme.border,
shape = RoundedCornerShape(25),
).align(Alignment.TopEnd),
) {
Text(
text = unwatchedCount.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
// fontSize = 16.sp,
modifier = Modifier.padding(4.dp),
)
}
}
watchedPercent?.let { percent ->
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.background(
MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape)
.height(Cards.playedPercentHeight)
.fillMaxWidth((percent / 100.0).toFloat()),
)
}
}
}
}
}

View file

@ -0,0 +1,125 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.util.FocusPair
@Composable
fun ItemRow(
title: String,
items: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
cardContent: @Composable (
index: Int,
item: BaseItem?,
modifier: Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
) -> Unit,
modifier: Modifier = Modifier,
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
) {
val state = rememberLazyListState()
val firstFocus = remember { FocusRequester() }
var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = title,
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(focusPair?.focusRequester ?: firstFocus),
) {
itemsIndexed(items) { index, item ->
val cardModifier =
if (index == 0 && focusPair == null) {
Modifier.focusRequester(firstFocus)
} else {
if (focusPair != null && focusPair.column == index) {
Modifier.focusRequester(focusPair.focusRequester)
} else {
Modifier
}
}.onFocusChanged {
if (it.isFocused) {
focusedIndex = index
}
cardOnFocus?.invoke(it.isFocused, index)
}
cardContent.invoke(
index,
item,
cardModifier,
{ if (item != null) onClickItem.invoke(item) },
{ if (item != null) onLongClickItem.invoke(item) },
)
}
}
}
}
@Composable
fun BannerItemRow(
title: String,
items: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
aspectRatioOverride: Float? = null,
) = ItemRow(
title = title,
items = items,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
modifier = modifier,
cardContent = { index, item, modifier, onClick, onLongClick ->
BannerCard(
name = title,
imageUrl = item?.imageUrl,
aspectRatio =
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
cornerText = item?.data?.indexNumber?.let { "E$it" },
played = item?.data?.userData?.played ?: false,
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
interactionSource = null,
)
},
focusPair = focusPair,
cardOnFocus = cardOnFocus,
)

View file

@ -0,0 +1,118 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay
/**
* A Card for a [Person] such as an actor or director
*/
@Composable
fun PersonCard(
item: Person,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val hideOverlayDelay = 1_000L
val focused = interactionSource.collectIsFocusedAsState().value
var focusedAfterDelay by remember { mutableStateOf(false) }
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier,
) {
Card(
modifier = Modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
ItemCardImage(
imageUrl = item.imageUrl,
name = item.name,
showOverlay = false,
favorite = false,
watched = false,
unwatchedCount = -1,
watchedPercent = null,
useFallbackText = false,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f), // TODO,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = item.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
item.role?.let {
Text(
text = item.role,
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}
}

View file

@ -0,0 +1,64 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.ifElse
@Composable
fun PersonRow(
people: List<Person>,
onClick: (Person) -> Unit,
modifier: Modifier = Modifier,
onLongClick: ((Person) -> Unit)? = null,
) {
val firstFocus = remember { FocusRequester() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = "People",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
LazyRow(
state = rememberLazyListState(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth()
.focusRestorer(firstFocus),
) {
itemsIndexed(people) { index, item ->
PersonCard(
item = item,
onClick = { onClick.invoke(item) },
onLongClick = { onLongClick?.invoke(item) },
modifier =
Modifier
.width(120.dp)
.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
)
}
}
}
}

View file

@ -0,0 +1,134 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay
/**
* A Card for a TV Show Season, but can generically show most items
*/
@Composable
fun SeasonCard(
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false,
) {
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)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f)
val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio)
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.size(width, height),
) {
Card(
modifier =
Modifier
.size(imageWidth, imageHeight)
.aspectRatio(aspectRatio),
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Box(
modifier =
Modifier
.fillMaxSize(),
) {
ItemCardImage(
imageUrl = item?.imageUrl,
name = item?.name,
showOverlay = showImageOverlay,
favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
watchedPercent = dto?.userData?.playedPercentage,
useFallbackText = false,
modifier =
Modifier
.fillMaxSize(),
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = item?.subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}

View file

@ -0,0 +1,52 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun CircularProgress(modifier: Modifier = Modifier) {
Box(modifier = modifier) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
/**
* Fill the space with a loading indicator and take focus
*/
@Composable
fun LoadingPage(modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.fillMaxSize()
.focusRequester(focusRequester)
.focusable(),
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier =
Modifier
.align(Alignment.Center)
.size(48.dp),
)
}
}

View file

@ -0,0 +1,299 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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 com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class CollectionFolderViewModel
@Inject
constructor(
api: ApiClient,
) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<List<BaseItem?>>(listOf())
val sortAndDirection = MutableLiveData<SortAndDirection>()
fun init(
itemId: UUID,
potential: BaseItem?,
sortAndDirection: SortAndDirection,
recursive: Boolean,
): Job =
viewModelScope.launch(
LoadingExceptionHandler(
loading,
"Error loading collection $itemId",
) + Dispatchers.IO,
) {
fetchItem(itemId, potential)
loadResults(sortAndDirection, recursive)
}
fun loadResults(
sortAndDirection: SortAndDirection,
recursive: Boolean,
) {
item.value?.let { item ->
viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
pager.value = listOf()
loading.value = LoadingState.Loading
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
}
val includeItemTypes =
when (item.data.collectionType) {
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
CollectionType.MUSIC ->
listOf(
BaseItemKind.AUDIO,
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.MUSIC_ALBUM,
)
CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET)
CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST)
else -> listOf()
}
val request =
GetItemsRequest(
parentId = item.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes,
recursive = recursive,
excludeItemIds = listOf(item.id),
sortBy =
listOf(
sortAndDirection.sort,
ItemSortBy.SORT_NAME,
ItemSortBy.PRODUCTION_YEAR,
),
sortOrder =
listOf(
sortAndDirection.direction,
SortOrder.ASCENDING,
SortOrder.ASCENDING,
),
fields = DefaultItemFields,
)
val newPager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
)
newPager.init()
if (newPager.isNotEmpty()) newPager.getBlocking(0)
withContext(Dispatchers.Main) {
pager.value = newPager
loading.value = LoadingState.Success
}
}
}
}
suspend fun positionOfLetter(letter: Char): Int? =
item.value?.let { item ->
val includeItemTypes =
when (item.data.collectionType) {
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
else -> listOf()
}
val request =
GetItemsRequest(
parentId = item.id,
includeItemTypes = includeItemTypes,
nameLessThan = letter.toString(),
limit = 0,
enableTotalRecordCount = true,
recursive = true,
)
val result by GetItemsRequestHandler.execute(api, request)
result.totalRecordCount
}
}
/**
* Shows a collection folder as a grid
*
* This is the "Library" tab for Movies or TV shows
*/
@Composable
fun CollectionFolderGrid(
preferences: UserPreferences,
destination: Destination.MediaItem,
recursive: Boolean,
onClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(),
initialSortAndDirection: SortAndDirection =
SortAndDirection(
ItemSortBy.SORT_NAME,
SortOrder.ASCENDING,
),
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
OneTimeLaunchedEffect {
viewModel.init(destination.itemId, destination.item, initialSortAndDirection, recursive)
}
val sortAndDirection by viewModel.sortAndDirection.observeAsState(initialSortAndDirection)
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
val pager by viewModel.pager.observeAsState()
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> {
pager?.let { pager ->
CollectionFolderGridContent(
preferences,
item!!,
pager,
sortAndDirection = sortAndDirection,
modifier = modifier,
onClickItem = onClickItem,
onSortChange = {
viewModel.loadResults(it, recursive)
},
showTitle = showTitle,
positionCallback = positionCallback,
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
)
}
}
}
}
@Composable
fun CollectionFolderGridContent(
preferences: UserPreferences,
item: BaseItem,
pager: List<BaseItem?>,
sortAndDirection: SortAndDirection,
onClickItem: (BaseItem) -> Unit,
onSortChange: (SortAndDirection) -> Unit,
letterPosition: suspend (Char) -> Int,
modifier: Modifier = Modifier,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
val title = item.name ?: item.data.collectionType?.name ?: "Collection"
val sortOptions =
when (item.data.collectionType) {
CollectionType.MOVIES -> MovieSortOptions
CollectionType.TVSHOWS -> SeriesSortOptions
CollectionType.HOMEVIDEOS -> VideoSortOptions
else -> listOf(ItemSortBy.SORT_NAME, ItemSortBy.DATE_CREATED, ItemSortBy.RANDOM)
}
var showHeader by rememberSaveable { mutableStateOf(true) }
val gridFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier,
) {
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
if (showTitle) {
Text(
text = title,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
SortByButton(
sortOptions = sortOptions,
current = sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier,
)
}
CardGrid(
pager = pager,
onClickItem = onClickItem,
onLongClickItem = {},
letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
modifier = Modifier.fillMaxSize(),
initialPosition = 0,
positionCallback = { columns, position ->
showHeader = position < columns
positionCallback?.invoke(columns, position)
},
)
}
}

View file

@ -0,0 +1,415 @@
package com.github.damontecres.wholphin.ui.components
import android.view.KeyEvent
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Button
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Parameters for rendering a [DialogPopup]
*/
data class DialogParams(
val fromLongClick: Boolean,
val title: String,
val items: List<DialogItem>,
)
sealed interface DialogItemEntry
data object DialogItemDivider : DialogItemEntry
data class DialogItem(
val headlineContent: @Composable () -> Unit,
val onClick: () -> Unit,
val overlineContent: @Composable (() -> Unit)? = null,
val supportingContent: @Composable (() -> Unit)? = null,
val leadingContent: @Composable (BoxScope.() -> Unit)? = null,
val trailingContent: @Composable (() -> Unit)? = null,
val enabled: Boolean = true,
) : DialogItemEntry {
constructor(
text: String,
@StringRes iconStringRes: Int,
onClick: () -> Unit,
) : this(
headlineContent = {
Text(
text = text,
// color = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
leadingContent = {
Text(
text = stringResource(id = iconStringRes),
// color = MaterialTheme.colorScheme.onPrimaryContainer,
fontFamily = FontAwesome,
)
},
onClick = onClick,
)
constructor(
text: String,
icon: ImageVector,
iconColor: Color? = null,
onClick: () -> Unit,
) : this(
headlineContent = {
Text(
text = text,
// color = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
leadingContent = {
Icon(
imageVector = icon,
contentDescription = text,
tint = iconColor ?: LocalContentColor.current,
)
},
onClick = onClick,
)
constructor(
text: String,
onClick: () -> Unit,
) : this(
headlineContent = {
Text(
text = text,
// color = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
onClick = onClick,
)
companion object {
fun divider(): DialogItemEntry = DialogItemDivider
}
}
/**
* Show a dialog with a list of entries.
*
* @param waitToLoad items start as disabled for about ~1s, which is useful if the dialog spawned from a long press
*/
@Composable
fun DialogPopup(
showDialog: Boolean,
title: String,
dialogItems: List<DialogItemEntry>,
onDismissRequest: () -> Unit,
dismissOnClick: Boolean = true,
waitToLoad: Boolean = true,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 3.dp,
) {
var waiting by remember { mutableStateOf(waitToLoad) }
if (showDialog) {
if (waitToLoad) {
LaunchedEffect(Unit) {
// This is a hack because a long click will propagate here and click the first list item
// So this disables the list items assuming the user will stop pressing when the dialog appears
// This is also bypassed in the code below if the user releases the enter/d-pad center button
waiting = true
delay(1000)
waiting = false
}
} else {
waiting = false
}
Dialog(
onDismissRequest = onDismissRequest,
properties = properties,
) {
val elevatedContainerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
LazyColumn(
modifier =
Modifier
// .widthIn(min = 520.dp, max = 300.dp)
// .dialogFocusable()
.graphicsLayer {
this.clip = true
this.shape = RoundedCornerShape(28.0.dp)
}.drawBehind { drawRect(color = elevatedContainerColor) }
.padding(PaddingValues(24.dp))
.onKeyEvent { event ->
val code = event.nativeKeyEvent.keyCode
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
code in
setOf(
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER,
)
) {
waiting = false
}
false
},
) {
stickyHeader {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
items(dialogItems) {
when (it) {
is DialogItemDivider -> HorizontalDivider(Modifier.height(16.dp))
is DialogItem ->
ListItem(
selected = false,
enabled = !waiting && it.enabled,
onClick = {
if (dismissOnClick) {
onDismissRequest.invoke()
}
it.onClick.invoke()
},
headlineContent = it.headlineContent,
overlineContent = it.overlineContent,
supportingContent = it.supportingContent,
leadingContent = it.leadingContent,
trailingContent = it.trailingContent,
modifier = Modifier,
)
}
}
}
}
}
}
@Composable
fun DialogPopup(
params: DialogParams,
onDismissRequest: () -> Unit,
dismissOnClick: Boolean = true,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 3.dp,
) = DialogPopup(
showDialog = true,
waitToLoad = params.fromLongClick,
title = params.title,
dialogItems = params.items,
onDismissRequest = onDismissRequest,
dismissOnClick = dismissOnClick,
properties = properties,
elevation = elevation,
)
/**
* A dialog that can be scrolled, typically for longer text content
*/
@Composable
fun ScrollableDialog(
onDismissRequest: () -> Unit,
content: LazyListScope.() -> Unit,
) {
val scrollAmount = 100f
val columnState = rememberLazyListState()
val scope = rememberCoroutineScope()
fun scroll(reverse: Boolean = false) {
scope.launch(ExceptionHandler()) {
columnState.scrollBy(if (reverse) -scrollAmount else scrollAmount)
}
}
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content,
modifier =
Modifier
.width(600.dp)
.heightIn(max = 380.dp)
.focusable()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(8.dp),
).onKeyEvent {
if (it.type == KeyEventType.KeyUp) {
return@onKeyEvent false
}
if (it.key == Key.DirectionDown) {
scroll(false)
return@onKeyEvent true
}
if (it.key == Key.DirectionUp) {
scroll(true)
return@onKeyEvent true
}
return@onKeyEvent false
},
)
}
}
/**
* Shows a basic dialog
*/
@Composable
fun BasicDialog(
onDismissRequest: () -> Unit,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 3.dp,
content: @Composable () -> Unit,
) {
Dialog(
onDismissRequest = onDismissRequest,
properties = properties,
) {
Box(
modifier =
Modifier
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation),
shape = RoundedCornerShape(8.dp),
),
) {
content()
}
}
}
/**
* Shows a confirmation dialog
*/
@Composable
fun ConfirmDialog(
title: String,
body: String?,
onCancel: () -> Unit,
onConfirm: () -> Unit,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 3.dp,
) = BasicDialog(
onDismissRequest = onCancel,
properties = properties,
elevation = elevation,
content = {
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier)
},
)
/**
* Content for a confirmation dialog
*/
@Composable
fun ConfirmDialogContent(
title: String,
body: String?,
onCancel: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp),
modifier = modifier,
) {
item {
Text(
text = title,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillParentMaxWidth(),
)
}
body?.let {
item {
Text(
text = body,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
item {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth(),
) {
Button(
onClick = onCancel,
) {
Text(
text = stringResource(R.string.cancel),
)
}
Button(
onClick = onConfirm,
) {
Text(
text = stringResource(R.string.confirm),
)
}
}
}
}
}

View file

@ -0,0 +1,62 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@Composable
fun DotSeparatedRow(
texts: List<String>,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
texts.forEachIndexed { index, text ->
Text(
text = text,
style = textStyle,
color = MaterialTheme.colorScheme.onSurface,
)
if (index != texts.lastIndex) {
Box(
modifier =
Modifier
.padding(horizontal = 8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 1f))
.size(4.dp),
)
}
}
}
}

View file

@ -0,0 +1,206 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TextFieldDefaults.Container
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.google.protobuf.value
/**
* A modified [BasicTextField] that looks & fits better with TV material controls
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditTextBox(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
leadingIcon: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
height: Dp = 40.dp,
isInputValid: (String) -> Boolean = { true },
supportingText: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
// From ButtonDefaults
val colors =
TextFieldDefaults.colors(
unfocusedTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
focusedTextColor = MaterialTheme.colorScheme.inverseOnSurface,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f),
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
errorContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
focusedIndicatorColor = MaterialTheme.colorScheme.border,
unfocusedLabelColor = Color.Unspecified,
)
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
BasicTextField(
value = value,
modifier =
modifier
.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = height,
).height(height),
onValueChange = onValueChange,
enabled = enabled,
readOnly = readOnly,
textStyle = MaterialTheme.typography.bodyLarge.merge(MaterialTheme.colorScheme.onPrimaryContainer),
cursorBrush = SolidColor(colors.cursorColor),
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
interactionSource = interactionSource,
singleLine = true,
maxLines = 1,
minLines = 1,
visualTransformation =
if (keyboardOptions.keyboardType == KeyboardType.Password ||
keyboardOptions.keyboardType == KeyboardType.NumberPassword
) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
decorationBox =
@Composable { innerTextField ->
// places leading icon, text field with label and placeholder, trailing icon
TextFieldDefaults.DecorationBox(
value = value,
visualTransformation =
if (keyboardOptions.keyboardType == KeyboardType.Password ||
keyboardOptions.keyboardType == KeyboardType.NumberPassword
) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
innerTextField = innerTextField,
placeholder = placeholder,
label = null,
leadingIcon = leadingIcon,
trailingIcon = null,
prefix = null,
suffix = null,
supportingText = supportingText,
shape = CircleShape,
singleLine = true,
enabled = enabled,
isError = false,
interactionSource = interactionSource,
colors = colors,
contentPadding =
PaddingValues(
horizontal = 8.dp,
vertical = 10.dp,
),
container = {
Container(
enabled = enabled,
isError = !isInputValid.invoke(value),
interactionSource = interactionSource,
modifier = Modifier,
colors = colors,
shape = CircleShape,
focusedIndicatorLineThickness = 4.dp,
unfocusedIndicatorLineThickness = 0.dp,
)
},
)
},
)
}
}
/**
* And [EditTextBox] styles for searches
*/
@Composable
fun SearchEditTextBox(
value: String,
onValueChange: (String) -> Unit,
onSearchClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
height: Dp = 40.dp,
) {
EditTextBox(
value,
onValueChange,
modifier,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
imeAction = ImeAction.Search,
),
keyboardActions =
KeyboardActions(
onSearch = {
onSearchClick.invoke()
this.defaultKeyboardAction(ImeAction.Done)
},
),
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
},
enabled,
readOnly,
height,
)
}
@PreviewTvSpec
@Composable
private fun EditTextBoxPreview() {
WholphinTheme {
Column {
EditTextBox(
value = "string",
onValueChange = {},
)
SearchEditTextBox(
value = "search query",
onValueChange = {},
onSearchClick = { },
)
}
}
}

View file

@ -0,0 +1,56 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.util.LoadingState
/**
* Displays an error message and/or exception
*/
@Composable
fun ErrorMessage(
message: String?,
exception: Throwable?,
modifier: Modifier = Modifier,
) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
message?.let {
item {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleLarge,
)
}
}
exception?.localizedMessage?.let {
item {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
@Composable
fun ErrorMessage(
error: LoadingState.Error,
modifier: Modifier = Modifier,
) = ErrorMessage(
message = error.message,
exception = error.exception,
modifier = modifier,
)

View file

@ -0,0 +1,14 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.mikepenz.aboutlibraries.ui.compose.android.rememberLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
@Composable
fun LicenseInfo(modifier: Modifier = Modifier) {
val libraries by rememberLibraries()
LibrariesContainer(libraries, modifier)
}

View file

@ -0,0 +1,70 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
@Composable
fun OverviewText(
overview: String,
maxLines: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
textBoxHeight: Dp = maxLines * 20.dp,
enabled: Boolean = true,
) {
val context = LocalContext.current
val isFocused = interactionSource.collectIsFocusedAsState().value
val bgColor =
if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
} else {
Color.Unspecified
}
Box(
modifier =
modifier
.background(bgColor, shape = RoundedCornerShape(8.dp))
.playSoundOnFocus(true)
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = LocalIndication.current,
) {
playOnClickSound(context)
onClick.invoke()
},
) {
Text(
text = overview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.padding(8.dp)
.height(textBoxHeight),
)
}
}

View file

@ -0,0 +1,130 @@
package com.github.damontecres.wholphin.ui.components
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.DefaultButtonPadding
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.ifElse
import kotlin.time.Duration
/**
* An icon button typically used in a row for playing media
*/
@Composable
fun PlayButton(
@StringRes title: Int,
resume: Duration,
icon: ImageVector,
onClick: (position: Duration) -> Unit,
modifier: Modifier = Modifier,
mirrorIcon: Boolean = false,
) {
Button(
onClick = { onClick.invoke(resume) },
modifier = modifier,
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
)
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(title),
style = MaterialTheme.typography.titleSmall,
)
}
}
/**
* An icon button typically used in a row for playing media
*
* Only shows the icon until focused when it expands to show the title
*/
@Composable
fun ExpandablePlayButton(
@StringRes title: Int,
resume: Duration,
icon: ImageVector,
onClick: (position: Duration) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
mirrorIcon: Boolean = false,
) {
val isFocused = interactionSource.collectIsFocusedAsState().value
Button(
onClick = { onClick.invoke(resume) },
modifier = modifier,
contentPadding = DefaultButtonPadding,
interactionSource = interactionSource,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }),
)
AnimatedVisibility(isFocused) {
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(title),
style = MaterialTheme.typography.titleSmall,
)
}
}
}
/**
* Similar to [ExpandablePlayButton], but uses a [FontAwesome] string instead of an Icon
*/
@Composable
fun ExpandableFaButton(
@StringRes title: Int,
@StringRes iconStringRes: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val isFocused = interactionSource.collectIsFocusedAsState().value
Button(
onClick = onClick,
modifier = modifier.heightIn(min = 40.dp),
contentPadding = DefaultButtonPadding,
interactionSource = interactionSource,
) {
Text(
text = stringResource(iconStringRes),
style = MaterialTheme.typography.titleSmall,
fontSize = 16.sp,
fontFamily = FontAwesome,
textAlign = TextAlign.Center,
modifier = Modifier,
)
AnimatedVisibility(isFocused) {
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(title),
style = MaterialTheme.typography.titleSmall,
)
}
}
}

View file

@ -0,0 +1,215 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Standard row of [PlayButton] including Play (or Resume & Restart) & More
*/
@Composable
fun PlayButtons(
resumePosition: Duration,
playOnClick: (position: Duration) -> Unit,
moreOnClick: () -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val firstFocus = remember { FocusRequester() }
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
modifier =
modifier
.focusGroup()
.focusRestorer(firstFocus),
) {
if (resumePosition > Duration.ZERO) {
item {
// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
PlayButton(
R.string.resume,
resumePosition,
Icons.Default.PlayArrow,
playOnClick,
Modifier
.onFocusChanged(buttonOnFocusChanged)
.focusRequester(firstFocus)
.focusRequester(focusRequester),
)
}
item {
PlayButton(
R.string.restart,
Duration.ZERO,
Icons.Default.Refresh,
playOnClick,
Modifier
.onFocusChanged(buttonOnFocusChanged),
mirrorIcon = true,
)
}
} else {
item {
// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
PlayButton(
R.string.play,
Duration.ZERO,
Icons.Default.PlayArrow,
playOnClick,
Modifier
.onFocusChanged(buttonOnFocusChanged)
.focusRequester(firstFocus)
.focusRequester(focusRequester),
)
}
}
// More button
item {
Button(
onClick = moreOnClick,
onLongClick = {},
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null,
)
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(R.string.more),
style = MaterialTheme.typography.titleSmall,
)
}
}
}
}
/**
* Standard row of [ExpandablePlayButton] including Play (or Resume & Restart), Mark played, & More
*/
@Composable
fun ExpandablePlayButtons(
resumePosition: Duration,
watched: Boolean,
playOnClick: (position: Duration) -> Unit,
watchOnClick: () -> Unit,
moreOnClick: () -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
val firstFocus = remember { FocusRequester() }
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(8.dp),
modifier =
modifier
.focusGroup()
.focusRestorer(firstFocus),
) {
if (resumePosition > Duration.ZERO) {
item("play") {
ExpandablePlayButton(
R.string.resume,
resumePosition,
Icons.Default.PlayArrow,
playOnClick,
Modifier
.onFocusChanged(buttonOnFocusChanged)
.focusRequester(firstFocus),
)
}
item("restart") {
ExpandablePlayButton(
R.string.restart,
Duration.ZERO,
Icons.Default.Refresh,
playOnClick,
Modifier.onFocusChanged(buttonOnFocusChanged),
mirrorIcon = true,
)
}
} else {
item("play") {
ExpandablePlayButton(
R.string.play,
Duration.ZERO,
Icons.Default.PlayArrow,
playOnClick,
Modifier
.onFocusChanged(buttonOnFocusChanged)
.focusRequester(firstFocus),
)
}
}
// Watched button
item("watched") {
ExpandableFaButton(
title = if (watched) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
onClick = watchOnClick,
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
)
}
// More button
item("more") {
ExpandablePlayButton(
R.string.more,
Duration.ZERO,
Icons.Default.MoreVert,
{ moreOnClick.invoke() },
Modifier.onFocusChanged(buttonOnFocusChanged),
)
}
}
}
@PreviewTvSpec
@Composable
private fun ExpandablePlayButtonsPreview() {
WholphinTheme(true) {
ExpandablePlayButtons(
resumePosition = 10.seconds,
watched = false,
playOnClick = {},
watchOnClick = {},
moreOnClick = {},
buttonOnFocusChanged = {},
modifier = Modifier,
)
}
}

View file

@ -0,0 +1,213 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.tryRequestFocus
enum class StarRatingPrecision {
FULL,
HALF,
QUARTER,
;
companion object {
fun fromFloat(value: Float): StarRatingPrecision =
if (value <= .25f) {
QUARTER
} else if (value <= .5f) {
HALF
} else {
FULL
}
}
}
val FilledStarColor = Color(0xFFFFC700)
val EmptyStarColor = Color(0x2AFFC700)
val ratingBarHeight: Dp = 32.dp
/**
* Shows a rating out of 5 stars
*/
@Composable
fun StarRating(
rating100: Int,
onRatingChange: (Int) -> Unit,
precision: StarRatingPrecision,
enabled: Boolean,
playSoundOnFocus: Boolean,
modifier: Modifier = Modifier,
bgColor: Color = AppColors.TransparentBlack75, // MaterialTheme.colorScheme.background,
) {
val context = LocalContext.current
var tempRating by remember(rating100) { mutableIntStateOf(rating100) }
val percentage = (if (enabled) tempRating else rating100) / 100f
val focusRequesters = remember { List(5) { FocusRequester() } }
Box(
modifier =
modifier
.clip(RoundedCornerShape(16.dp))
.background(bgColor),
) {
LazyRow(
modifier =
Modifier
.selectableGroup()
.padding(4.dp)
.drawWithCache {
onDrawWithContent {
drawContent()
if (percentage in 0f..<1f) {
drawRect(
color = bgColor,
topLeft = Offset(size.width * percentage, 0f),
blendMode = BlendMode.SrcAtop,
)
}
}
}.focusGroup()
.focusProperties {
onEnter = {
val index =
if (rating100 <= 20) {
0
} else if (rating100 <= 40) {
1
} else if (rating100 <= 60) {
2
} else if (rating100 <= 80) {
3
} else {
4
}
focusRequesters[index].tryRequestFocus()
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
for (i in 1..5) {
item {
val isRated = (if (enabled) tempRating else rating100) >= (i * 20)
val icon = Icons.Filled.Star
var focused by remember { mutableStateOf(false) }
val focusedColor =
if (focused) {
MaterialTheme.colorScheme.border
} else {
Color.Unspecified
}
Box(
modifier =
Modifier
.clip(RoundedCornerShape(16.dp))
.background(focusedColor),
) {
Icon(
imageVector = icon,
tint = FilledStarColor,
contentDescription = null,
modifier =
if (enabled) {
Modifier
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) {
tempRating = i * 20
} else {
tempRating = rating100
}
}.playSoundOnFocus(playSoundOnFocus)
.focusRequester(focusRequesters[i - 1])
.focusProperties {
left =
if (i == 1) focusRequesters.last() else FocusRequester.Default
right =
if (i == 5) focusRequesters.first() else FocusRequester.Default
}.selectable(
selected = isRated,
onClick = {
if (playSoundOnFocus) playOnClickSound(context)
val newRating100 =
when (precision) {
StarRatingPrecision.FULL ->
if (i == 1 && rating100 > 0 && rating100 <= 20) 0 else i * 20
StarRatingPrecision.HALF -> {
if (rating100 > i * 20) {
i * 20
} else if (rating100 == i * 20) {
i * 20 - 10
} else if (rating100 == i * 20 - 10) {
(i - 1) * 20
} else if (i == 1 && rating100 == 0) {
20
} else if (i == 1 && rating100 > 10) {
10
} else if (i == 1 && rating100 <= 10) {
0
} else {
(i) * 20
}
}
StarRatingPrecision.QUARTER -> {
// TODO
null
}
}
if (newRating100 != null) {
tempRating = newRating100
onRatingChange(newRating100)
}
},
)
} else {
Modifier
}.fillMaxHeight()
.aspectRatio(1f),
)
}
}
}
}
}
}

View file

@ -0,0 +1,157 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.HomeRow
import com.github.damontecres.wholphin.ui.main.HomeSection
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class RecommendedMovieViewModel
@Inject
constructor(
private val api: ApiClient,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val rows = MutableLiveData<List<HomeRow>>()
fun init(
preferences: UserPreferences,
parentId: UUID,
) {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val resumeItemsRequest =
GetResumeItemsRequest(
parentId = parentId,
fields = DefaultItemFields,
includeItemTypes = listOf(BaseItemKind.MOVIE),
enableUserData = true,
)
val resumeItems =
ApiRequestPager(api, resumeItemsRequest, GetResumeItemsRequestHandler, viewModelScope)
val recentlyReleasedRequest =
GetItemsRequest(
parentId = parentId,
fields = DefaultItemFields,
includeItemTypes = listOf(BaseItemKind.MOVIE),
recursive = true,
enableUserData = true,
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
sortOrder = listOf(SortOrder.DESCENDING),
)
val recentlyReleasedItems =
ApiRequestPager(api, recentlyReleasedRequest, GetItemsRequestHandler, viewModelScope)
val recentlyAddedRequest =
GetItemsRequest(
parentId = parentId,
fields = DefaultItemFields,
includeItemTypes = listOf(BaseItemKind.MOVIE),
recursive = true,
enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_CREATED),
sortOrder = listOf(SortOrder.DESCENDING),
)
val recentlyAddedItems =
ApiRequestPager(api, recentlyAddedRequest, GetItemsRequestHandler, viewModelScope)
val suggestionsRequest =
GetSuggestionsRequest(
type = listOf(BaseItemKind.MOVIE),
)
val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope)
val unwatchedTopRatedRequest =
GetItemsRequest(
parentId = parentId,
fields = DefaultItemFields,
includeItemTypes = listOf(BaseItemKind.MOVIE),
recursive = true,
enableUserData = true,
isPlayed = false,
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
sortOrder = listOf(SortOrder.DESCENDING),
)
val unwatchedTopRatedItems =
ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
val rows = listOf(resumeItems, recentlyReleasedItems, recentlyAddedItems, suggestedItems, unwatchedTopRatedItems)
rows.forEach { it.init() }
val homeRows =
listOf(
HomeRow(HomeSection.RESUME, resumeItems, "Continue Watching"),
HomeRow(HomeSection.LATEST_MEDIA, recentlyReleasedItems, "Recently Released"),
HomeRow(HomeSection.LATEST_MEDIA, recentlyAddedItems, "Recently Added"),
HomeRow(HomeSection.NONE, suggestedItems, "Suggestions"),
HomeRow(HomeSection.NONE, unwatchedTopRatedItems, "Top Rated Unwatched"),
)
withContext(Dispatchers.Main) {
this@RecommendedMovieViewModel.rows.value = homeRows
loading.value = LoadingState.Success
}
}
}
}
/**
* The "recommended" tab of a movie library
*/
@Composable
fun RecommendedMovie(
preferences: UserPreferences,
parentId: UUID,
onClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
viewModel: RecommendedMovieViewModel = hiltViewModel(),
) {
OneTimeLaunchedEffect {
viewModel.init(preferences, parentId)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val rows by viewModel.rows.observeAsState(listOf())
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success ->
HomePageContent(
homeRows = rows,
onClickItem = onClickItem,
modifier = modifier,
)
}
}

View file

@ -0,0 +1,169 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.HomeRow
import com.github.damontecres.wholphin.ui.main.HomeSection
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class RecommendedTvShowViewModel
@Inject
constructor(
private val api: ApiClient,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val rows = MutableLiveData<List<HomeRow>>()
fun init(
preferences: UserPreferences,
parentId: UUID,
) {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val resumeItemsRequest =
GetResumeItemsRequest(
parentId = parentId,
fields = DefaultItemFields,
includeItemTypes = listOf(BaseItemKind.EPISODE),
enableUserData = true,
)
val resumeItems =
ApiRequestPager(api, resumeItemsRequest, GetResumeItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
val nextUpRequest =
GetNextUpRequest(
parentId = parentId,
fields = DefaultItemFields,
enableUserData = true,
)
val nextUpItems = ApiRequestPager(api, nextUpRequest, GetNextUpRequestHandler, viewModelScope, useSeriesForPrimary = true)
val recentlyReleasedRequest =
GetItemsRequest(
parentId = parentId,
fields = DefaultItemFields,
includeItemTypes = listOf(BaseItemKind.EPISODE),
recursive = true,
enableUserData = true,
sortBy = listOf(ItemSortBy.PREMIERE_DATE),
sortOrder = listOf(SortOrder.DESCENDING),
)
val recentlyReleasedItems =
ApiRequestPager(api, recentlyReleasedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
val recentlyAddedRequest =
GetItemsRequest(
parentId = parentId,
fields = DefaultItemFields,
includeItemTypes = listOf(BaseItemKind.EPISODE),
recursive = true,
enableUserData = true,
sortBy = listOf(ItemSortBy.DATE_CREATED),
sortOrder = listOf(SortOrder.DESCENDING),
)
val recentlyAddedItems =
ApiRequestPager(api, recentlyAddedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
val suggestionsRequest =
GetSuggestionsRequest(
type = listOf(BaseItemKind.SERIES),
)
val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope)
val unwatchedTopRatedRequest =
GetItemsRequest(
parentId = parentId,
fields = DefaultItemFields,
includeItemTypes = listOf(BaseItemKind.SERIES),
recursive = true,
enableUserData = true,
isPlayed = false,
sortBy = listOf(ItemSortBy.COMMUNITY_RATING),
sortOrder = listOf(SortOrder.DESCENDING),
)
val unwatchedTopRatedItems =
ApiRequestPager(api, unwatchedTopRatedRequest, GetItemsRequestHandler, viewModelScope, useSeriesForPrimary = true)
val rows =
listOf(resumeItems, nextUpItems, recentlyReleasedItems, recentlyAddedItems, suggestedItems, unwatchedTopRatedItems)
rows.forEach { it.init() }
val homeRows =
listOf(
HomeRow(HomeSection.RESUME, resumeItems, "Continue Watching"),
HomeRow(HomeSection.NEXT_UP, nextUpItems, "Next Up"),
HomeRow(HomeSection.LATEST_MEDIA, recentlyReleasedItems, "Recently Released"),
HomeRow(HomeSection.LATEST_MEDIA, recentlyAddedItems, "Recently Added"),
HomeRow(HomeSection.NONE, suggestedItems, "Suggestions"),
HomeRow(HomeSection.NONE, unwatchedTopRatedItems, "Top Rated Unwatched"),
)
withContext(Dispatchers.Main) {
this@RecommendedTvShowViewModel.rows.value = homeRows
loading.value = LoadingState.Success
}
}
}
}
/**
* The "recommended" tab of a TV show library
*/
@Composable
fun RecommendedTvShow(
preferences: UserPreferences,
parentId: UUID,
onClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
viewModel: RecommendedTvShowViewModel = hiltViewModel(),
) {
OneTimeLaunchedEffect {
viewModel.init(preferences, parentId)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val rows by viewModel.rows.observeAsState(listOf())
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success ->
HomePageContent(
homeRows = rows,
onClickItem = onClickItem,
modifier = modifier,
)
}
}

View file

@ -0,0 +1,113 @@
package com.github.damontecres.wholphin.ui.components
import android.view.KeyEvent
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.handleDPadKeyEvents
/**
* A TV capable control for choosing a value
*/
@Composable
fun SliderBar(
value: Long,
min: Long,
max: Long,
onChange: (Long) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
interval: Int = 1,
color: Color = MaterialTheme.colorScheme.border,
) {
val isFocused by interactionSource.collectIsFocusedAsState()
val animatedIndicatorHeight by animateDpAsState(
targetValue = 6.dp.times((if (isFocused) 2f else 1f)),
)
var currentValue by remember(value) { mutableLongStateOf(value) }
val percent = currentValue.toFloat() / (max - min)
val handleSeekEventModifier =
Modifier.handleDPadKeyEvents(
triggerOnAction = KeyEvent.ACTION_DOWN,
onCenter = {
onChange(currentValue)
},
onLeft = {
if (currentValue <= min) {
currentValue = max
} else {
currentValue = (currentValue - interval).coerceAtLeast(min)
}
onChange(currentValue)
},
onRight = {
if (currentValue >= max) {
currentValue = min
} else {
currentValue = (currentValue + interval).coerceAtMost(max)
}
onChange(currentValue)
},
)
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Canvas(
modifier =
Modifier
.fillMaxWidth()
.height(animatedIndicatorHeight)
.padding(horizontal = 4.dp)
.then(handleSeekEventModifier)
.focusable(interactionSource = interactionSource),
onDraw = {
val yOffset = size.height.div(2)
drawLine(
color = color.copy(alpha = 0.15f),
start = Offset(x = 0f, y = yOffset),
end = Offset(x = size.width, y = yOffset),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawLine(
color = color,
start = Offset(x = 0f, y = yOffset),
end =
Offset(
// x = size.width.times(if (isSelected) seekProgress else progress),
x = size.width.times(percent),
y = yOffset,
),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawCircle(
color = Color.White,
radius = size.height + 2,
center = Offset(x = size.width.times(percent), y = yOffset),
)
},
)
}
}

View file

@ -0,0 +1,123 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.flip
import com.github.damontecres.wholphin.ui.data.getStringRes
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
/**
* Button that displays current sort option and provides ability to choose another
*
* Long pressing will reverse the current sort as will selecting the current sort option from the list
*/
@Composable
fun SortByButton(
sortOptions: List<ItemSortBy>,
current: SortAndDirection,
onSortChange: (SortAndDirection) -> Unit,
modifier: Modifier = Modifier,
) {
val currentSort = current.sort
val name = stringResource(getStringRes(currentSort))
val currentDirection = current.direction
var sortByDropDown by remember { mutableStateOf(false) }
val context = LocalContext.current
Box(modifier = modifier) {
Button(
onClick = { sortByDropDown = true },
onLongClick = {
onSortChange.invoke(current.flip())
},
) {
Text(
text =
buildAnnotatedString {
withStyle(SpanStyle(fontFamily = FontAwesome)) {
append(
stringResource(
if (currentDirection == SortOrder.ASCENDING) {
R.string.fa_caret_up
} else {
R.string.fa_caret_down
},
),
)
}
append(" ")
append(name)
},
)
}
DropdownMenu(
expanded = sortByDropDown,
onDismissRequest = { sortByDropDown = false },
) {
sortOptions
// .sortedBy { it.name }
.forEach { sortOption ->
DropdownMenuItem(
leadingIcon = {
if (sortOption == currentSort) {
if (currentDirection == SortOrder.ASCENDING) {
Text(
text = stringResource(R.string.fa_caret_up),
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontFamily = FontAwesome,
)
} else {
Text(
text = stringResource(R.string.fa_caret_down),
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontFamily = FontAwesome,
)
}
}
},
text = {
Text(
text = stringResource(getStringRes(sortOption)),
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
},
onClick = {
sortByDropDown = false
val newDirection =
if (currentSort == sortOption) {
currentDirection.flip()
} else {
currentDirection
}
onSortChange.invoke(
SortAndDirection(
sortOption,
newDirection,
),
)
},
)
}
}
}
}

View file

@ -0,0 +1,76 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
/**
* A labeled [Switch], but the entire composable is focusable & clickable
*/
@Composable
fun SwitchWithLabel(
label: String,
checked: Boolean,
onStateChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
enabled: Boolean = true,
) {
val context = LocalContext.current
val isFocused by interactionSource.collectIsFocusedAsState()
Row(
modifier =
modifier
.clip(shape = RoundedCornerShape(20.dp))
.background(
if (isFocused) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f)
},
).clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
role = Role.Switch,
onClick = {
if (enabled) {
onStateChange(!checked)
}
},
).padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
color = if (isFocused) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelLarge,
)
Spacer(modifier = Modifier.padding(start = 8.dp))
Switch(
checked = checked,
enabled = enabled,
onCheckedChange = {
onStateChange(!checked)
},
)
}
}

View file

@ -0,0 +1,132 @@
package com.github.damontecres.wholphin.ui.components
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@Composable
fun TableRowComposable(
row: TableRow,
modifier: Modifier = Modifier,
keyWeight: Float = .3f,
valueWeight: Float = .7f,
focusable: Boolean = true,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onBackground),
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val background =
if (isFocused && row.onClick != null) {
MaterialTheme.colorScheme.border.copy(alpha = .75f)
} else if (isFocused) {
MaterialTheme.colorScheme.onBackground.copy(alpha = .25f)
} else {
Color.Unspecified
}
Row(
modifier
.background(background)
.ifElse(
row.onClick != null,
ifTrueModifier =
Modifier
.clickable(
enabled = true,
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = { row.onClick?.invoke() },
),
ifFalseModifier =
Modifier.focusable(
enabled = focusable, // TODO, this allows scrolling
interactionSource = interactionSource,
),
),
) {
val keyModifier =
Modifier
.weight(keyWeight)
val valueModifier =
Modifier
.weight(valueWeight)
ProvideTextStyle(textStyle) {
Box(modifier = keyModifier) {
row.key.invoke(this, Modifier.padding(4.dp))
}
Box(modifier = valueModifier) {
row.value.invoke(this, Modifier.padding(4.dp))
}
}
}
}
data class TableRow(
val key: @Composable BoxScope.(modifier: Modifier) -> Unit,
val value: @Composable BoxScope.(modifier: Modifier) -> Unit,
val onClick: (() -> Unit)? = null,
) {
constructor(key: String, value: String, onClick: (() -> Unit)? = null) : this(
{ modifier: Modifier -> Text(text = "$key:", modifier = modifier) },
{ modifier: Modifier -> Text(text = value, modifier = modifier) },
onClick,
)
companion object {
@Composable
fun from(
@StringRes keyStringId: Int,
value: String?,
onClick: (() -> Unit)? = null,
): TableRow? =
if (value.isNotNullOrBlank()) {
TableRow(stringResource(keyStringId), value, onClick)
} else {
null
}
fun from(
context: Context,
@StringRes keyStringId: Int,
value: String?,
onClick: (() -> Unit)? = null,
): TableRow? =
if (value.isNotNullOrBlank()) {
TableRow(context.getString(keyStringId), value, onClick)
} else {
null
}
fun from(
key: String,
value: String?,
onClick: (() -> Unit)? = null,
): TableRow? =
if (value.isNotNullOrBlank()) {
TableRow(key, value, onClick)
} else {
null
}
}
}

View file

@ -0,0 +1,125 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playOnClickSound
/**
* An optionally clickable composable that displays a Key-Value pair vertically
*/
@Composable
fun TitleValueText(
title: String,
value: String,
modifier: Modifier = Modifier,
playSoundOnFocus: Boolean = false,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
) {
MaybeClickColumn(
onClick = onClick,
onLongClick = onLongClick,
playSoundOnFocus = playSoundOnFocus,
modifier = modifier,
) {
Text(
modifier = Modifier.alpha(0.8f),
text = title,
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = value,
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Normal),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun MaybeClickColumn(
playSoundOnFocus: Boolean,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
if (onClick != null || onLongClick != null) {
val context = LocalContext.current
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
if (playSoundOnFocus) {
LaunchedEffect(isFocused) {
if (isFocused) playOnClickSound(context)
}
}
val bgColor =
if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .75f)
} else {
Color.Unspecified
}
Column(
content = content,
modifier =
modifier
.background(bgColor, shape = RoundedCornerShape(4.dp))
.ifElse(isFocused, Modifier.scale(1.1f))
.combinedClickable(
enabled = true,
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = {
if (playSoundOnFocus) playOnClickSound(context)
onClick?.invoke()
},
onLongClick = {
if (playSoundOnFocus) playOnClickSound(context)
onLongClick?.invoke()
},
),
)
} else {
Column(content = content, modifier = modifier)
}
}

View file

@ -0,0 +1,191 @@
package com.github.damontecres.wholphin.ui.components.details
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.PlayButtons
import com.github.damontecres.wholphin.ui.components.StarRating
import com.github.damontecres.wholphin.ui.components.StarRatingPrecision
import com.github.damontecres.wholphin.ui.components.TitleValueText
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import java.util.SortedMap
import kotlin.time.Duration
@Composable
fun VideoDetailsHeader(
title: String,
subtitle: String?,
description: String?,
details: List<String>,
moreDetails: SortedMap<String, String>,
rating: Float?,
resumeTime: Duration?,
watched: Boolean,
favorite: Boolean,
bringIntoViewRequester: BringIntoViewRequester,
descriptionOnClick: () -> Unit,
moreOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
Column(
modifier = modifier,
) {
// Title
Text(
text = title,
color = MaterialTheme.colorScheme.onSurface,
style =
MaterialTheme.typography.displayMedium.copy(
shadow =
Shadow(
color = Color.DarkGray,
offset = Offset(5f, 2f),
blurRadius = 2f,
),
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
// Subtitle
if (subtitle.isNotNullOrBlank()) {
// Title
Text(
text = subtitle,
color = MaterialTheme.colorScheme.onSurface,
style =
MaterialTheme.typography.displaySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Column(
modifier = Modifier.alpha(0.75f),
) {
// Rating
if (rating != null) {
StarRating(
rating100 = (rating * 100).toInt(),
precision = StarRatingPrecision.HALF,
onRatingChange = {
// TODO
},
enabled = false,
modifier =
Modifier
.height(40.dp)
.padding(start = 12.dp),
playSoundOnFocus = false,
)
}
// Quick info
if (details.isNotEmpty()) {
DotSeparatedRow(
modifier = Modifier.padding(top = 6.dp, start = 8.dp),
textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
texts = details,
)
}
// Description
if (description.isNotNullOrBlank()) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value
val bgColor =
if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .75f)
} else {
Color.Unspecified
}
Box(
modifier =
Modifier
.background(bgColor, shape = RoundedCornerShape(8.dp))
.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) { bringIntoViewRequester.bringIntoView() }
}
}.playSoundOnFocus(true)
.clickable(
enabled = true,
interactionSource = interactionSource,
indication = LocalIndication.current,
) {
playOnClickSound(context)
descriptionOnClick.invoke()
},
) {
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(8.dp),
)
}
}
// Key-Values
if (moreDetails.isNotEmpty()) {
Row(
modifier =
Modifier
.padding(top = 8.dp, start = 16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
moreDetails.forEach { (key, value) ->
TitleValueText(key, value)
}
}
}
}
PlayButtons(
resumePosition = resumeTime ?: Duration.ZERO,
playOnClick = { position ->
// TODO
},
moreOnClick = { moreOnClick.invoke() },
buttonOnFocusChanged = {
scope.launch(ExceptionHandler()) { bringIntoViewRequester.bringIntoView() }
},
focusRequester = focusRequester,
modifier = Modifier,
)
}
}

View file

@ -0,0 +1,52 @@
package com.github.damontecres.wholphin.ui.data
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
data class ItemDetailsDialogInfo(
val title: String,
val overview: String?,
val files: List<String>,
)
@Composable
fun ItemDetailsDialog(
info: ItemDetailsDialogInfo,
onDismissRequest: () -> Unit,
) = ScrollableDialog(
onDismissRequest = onDismissRequest,
) {
item {
Text(
text = info.title,
style = MaterialTheme.typography.titleMedium,
)
}
if (info.overview.isNotNullOrBlank()) {
item {
Text(
text = info.overview,
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (info.files.isNotEmpty()) {
item {
Spacer(Modifier.height(8.dp))
}
}
items(info.files) { file ->
Text(
text = "- $file",
style = MaterialTheme.typography.bodyMedium,
)
}
}

View file

@ -0,0 +1,16 @@
package com.github.damontecres.wholphin.ui.data
import androidx.compose.runtime.saveable.Saver
import kotlinx.serialization.Serializable
@Serializable
data class RowColumn(
val row: Int,
val column: Int,
)
val RowColumnSaver =
Saver<RowColumn, List<Int>>(
save = { listOf(it.row, it.column) },
restore = { RowColumn(it[0], it[1]) },
)

View file

@ -0,0 +1,54 @@
package com.github.damontecres.wholphin.ui.data
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
data class SortAndDirection(
val sort: ItemSortBy,
val direction: SortOrder,
) {
fun flip() = copy(direction = direction.flip())
}
fun SortOrder.flip() = if (this == SortOrder.ASCENDING) SortOrder.DESCENDING else SortOrder.ASCENDING
val MovieSortOptions =
listOf(
ItemSortBy.SORT_NAME,
ItemSortBy.PREMIERE_DATE,
ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_PLAYED,
ItemSortBy.RANDOM,
)
val SeriesSortOptions =
listOf(
ItemSortBy.SORT_NAME,
ItemSortBy.PREMIERE_DATE,
ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_LAST_CONTENT_ADDED,
ItemSortBy.DATE_PLAYED,
ItemSortBy.RANDOM,
)
val VideoSortOptions =
listOf(
ItemSortBy.SORT_NAME,
ItemSortBy.DATE_CREATED,
ItemSortBy.DATE_PLAYED,
ItemSortBy.RANDOM,
)
@StringRes
fun getStringRes(sort: ItemSortBy): Int =
when (sort) {
ItemSortBy.SORT_NAME -> R.string.sort_by_name
ItemSortBy.PREMIERE_DATE -> R.string.sort_by_date_released
ItemSortBy.DATE_CREATED -> R.string.sort_by_date_added
ItemSortBy.DATE_LAST_CONTENT_ADDED -> R.string.sort_by_date_episode_added
ItemSortBy.DATE_PLAYED -> R.string.sort_by_date_played
ItemSortBy.RANDOM -> R.string.sort_by_random
else -> throw IllegalArgumentException("Unsupported sort option: $sort")
}

View file

@ -0,0 +1,454 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
import com.github.damontecres.wholphin.ui.playback.isForwardButton
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import timber.log.Timber
private const val DEBUG = false
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CardGrid(
pager: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int,
gridFocusRequester: FocusRequester,
showJumpButtons: Boolean,
showLetterButtons: Boolean,
modifier: Modifier = Modifier,
initialPosition: Int = 0,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
val columns = 6
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow)
val scope = rememberCoroutineScope()
val firstFocus = remember { FocusRequester() }
val zeroFocus = remember { FocusRequester() }
var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) }
var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) }
var alphabetFocus by remember { mutableStateOf(false) }
val focusOn = { index: Int ->
if (DEBUG) Timber.v("focusOn: focusedIndex=$focusedIndex, index=$index")
if (index != focusedIndex) {
previouslyFocusedIndex = focusedIndex
}
focusedIndex = index
}
// Wait for a recomposition to focus
val alphabetFocusRequester = remember { FocusRequester() }
LaunchedEffect(alphabetFocus) {
if (alphabetFocus) {
alphabetFocusRequester.tryRequestFocus()
}
alphabetFocus = false
}
val useBackToJump = true // uiConfig.preferences.interfacePreferences.scrollTopOnBack
val showFooter = true // uiConfig.preferences.interfacePreferences.showPositionFooter
val useJumpRemoteButtons = true // uiConfig.preferences.interfacePreferences.pageWithRemoteButtons
val jump2 =
remember {
if (pager.size >= 25_000) {
columns * 2000
} else if (pager.size >= 7_000) {
columns * 200
} else if (pager.size >= 2_000) {
columns * 50
} else {
columns * 20
}
}
val jump1 =
remember {
if (pager.size >= 25_000) {
columns * 500
} else if (pager.size >= 7_000) {
columns * 50
} else if (pager.size >= 2_000) {
columns * 15
} else {
columns * 6
}
}
val jump = { jump: Int ->
scope.launch(ExceptionHandler()) {
val newPosition =
(gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size)
if (DEBUG) Timber.d("newPosition=$newPosition")
focusOn(newPosition)
gridState.scrollToItem(newPosition, 0)
}
}
val jumpToTop = {
scope.launch(ExceptionHandler()) {
if (focusedIndex < (columns * 6)) {
// If close, animate the scroll
gridState.animateScrollToItem(0, 0)
} else {
gridState.scrollToItem(0, 0)
}
focusOn(0)
zeroFocus.tryRequestFocus()
}
}
var longPressing by remember { mutableStateOf(false) }
Row(
// horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.fillMaxSize()
.onKeyEvent {
if (DEBUG) Timber.d("onKeyEvent: ${it.nativeKeyEvent}")
if (useBackToJump && it.key == Key.Back && it.nativeKeyEvent.isLongPress) {
longPressing = true
val newPosition = previouslyFocusedIndex
if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition")
if (newPosition > 0) {
focusOn(newPosition)
scope.launch(ExceptionHandler()) {
gridState.scrollToItem(newPosition, -columns)
firstFocus.tryRequestFocus()
}
}
return@onKeyEvent true
} else if (it.type == KeyEventType.KeyUp) {
if (longPressing && it.key == Key.Back) {
longPressing = false
return@onKeyEvent true
}
longPressing = false
}
if (it.type != KeyEventType.KeyUp) {
return@onKeyEvent false
} else if (useBackToJump && it.key == Key.Back && focusedIndex > 0) {
jumpToTop()
return@onKeyEvent true
} else if (isPlayKeyUp(it)) {
// TODO play the focused item
return@onKeyEvent true
} else if (useJumpRemoteButtons && isForwardButton(it)) {
jump(jump1)
return@onKeyEvent true
} else if (useJumpRemoteButtons && isBackwardButton(it)) {
jump(-jump1)
return@onKeyEvent true
} else {
return@onKeyEvent false
}
},
) {
if (showJumpButtons && pager.isNotEmpty()) {
JumpButtons(
jump1 = jump1,
jump2 = jump2,
jumpClick = { jump(it) },
modifier = Modifier.align(Alignment.CenterVertically),
)
}
Box(
modifier = Modifier.weight(1f),
) {
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
state = gridState,
contentPadding = PaddingValues(16.dp),
modifier =
Modifier
.fillMaxSize()
.focusGroup()
.focusRestorer(firstFocus)
.focusProperties {
onExit = {
// Leaving the grid, so "forget" the position
// focusedIndex = -1
}
onEnter = {
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
focusedIndex = startPosition
}
}
},
) {
items(pager.size) { index ->
val mod =
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
Modifier
.focusRequester(firstFocus)
.focusRequester(gridFocusRequester)
.focusRequester(alphabetFocusRequester)
} else {
Modifier
}
val item = pager[index]
GridCard(
item = item,
onClick = {
if (item != null) {
focusedIndex = index
onClickItem.invoke(item)
}
},
onLongClick = { if (item != null) onLongClickItem.invoke(item) },
modifier =
mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
.onFocusChanged { focusState ->
if (DEBUG) {
Timber.v(
"$index isFocused=${focusState.isFocused}",
)
}
if (focusState.isFocused) {
// Focused, so set that up
focusOn(index)
positionCallback?.invoke(columns, index)
} else if (focusedIndex == index) {
// savedFocusedIndex = index
// // Was focused on this, so mark unfocused
// focusedIndex = -1
}
},
)
}
}
if (pager.isEmpty()) {
// focusedIndex = -1
Box(modifier = Modifier.fillMaxSize()) {
Text(
text = "No results",
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.align(Alignment.Center),
)
}
}
if (showFooter) {
// Footer
Box(
modifier =
Modifier
.align(Alignment.BottomCenter)
.background(AppColors.TransparentBlack50),
) {
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
// if (focusedIndex >= 0) {
// focusedIndex + 1
// } else {
// max(savedFocusedIndex, focusedIndexOnExit) + 1
// }
Text(
modifier = Modifier.padding(4.dp),
color = MaterialTheme.colorScheme.onBackground,
text = "$index / ${pager.size}",
)
}
}
}
// Letters
val currentLetter =
remember(focusedIndex) {
pager
.getOrNull(focusedIndex)
?.data
?.sortName
?.first()
?.uppercaseChar()
?.let {
if (it >= '0' && it <= '9') {
'#'
} else if (it >= 'A' && it <= 'Z') {
it
} else {
null
}
}
?: LETTERS[0]
}
if (showLetterButtons && pager.isNotEmpty()) {
AlphabetButtons(
currentLetter = currentLetter,
modifier = Modifier.align(Alignment.CenterVertically),
letterClicked = { letter ->
scope.launch(ExceptionHandler()) {
val jumpPosition = letterPosition.invoke(letter)
Timber.d("Alphabet jump to $jumpPosition")
if (jumpPosition >= 0) {
gridState.scrollToItem(jumpPosition)
focusOn(jumpPosition)
alphabetFocus = true
}
}
},
)
}
}
}
@Composable
fun JumpButtons(
jump1: Int,
jump2: Int,
jumpClick: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
JumpButton(R.string.fa_angles_up, -jump2, jumpClick)
JumpButton(R.string.fa_angle_up, -jump1, jumpClick)
JumpButton(R.string.fa_angle_down, jump1, jumpClick)
JumpButton(R.string.fa_angles_down, jump2, jumpClick)
}
}
@Composable
fun JumpButton(
@StringRes stringRes: Int,
jumpAmount: Int,
jumpClick: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Button(
modifier = modifier.width(40.dp),
contentPadding = PaddingValues(4.dp),
onClick = {
jumpClick.invoke(jumpAmount)
},
) {
Text(text = stringResource(stringRes), fontFamily = FontAwesome)
}
}
private const val LETTERS = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@Composable
fun AlphabetButtons(
currentLetter: Char,
letterClicked: (Char) -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val index = LETTERS.indexOf(currentLetter)
LaunchedEffect(currentLetter) {
scope.launch(ExceptionHandler()) {
val firstVisibleItemIndex = listState.firstVisibleItemIndex
val lastVisibleItemIndex =
listState.layoutInfo.visibleItemsInfo
.lastOrNull()
?.index ?: -1
if (index < firstVisibleItemIndex || index > lastVisibleItemIndex) {
listState.animateScrollToItem(index)
}
}
}
val focusRequesters = remember { List(LETTERS.length) { FocusRequester() } }
LazyColumn(
state = listState,
modifier =
modifier.focusProperties {
onEnter = {
focusRequesters[index.coerceIn(0, LETTERS.length - 1)].tryRequestFocus()
}
},
) {
items(
LETTERS.length,
key = { LETTERS[it] },
) { index ->
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
Button(
modifier =
Modifier
.size(24.dp)
.focusRequester(focusRequesters[index]),
contentPadding = PaddingValues(2.dp),
interactionSource = interactionSource,
onClick = {
letterClicked.invoke(LETTERS[index])
},
) {
val color =
if (!focused && LETTERS[index] == currentLetter) {
MaterialTheme.colorScheme.tertiary
} else {
LocalContentColor.current
}
Text(
text = LETTERS[index].toString(),
color = color,
)
}
}
}
}

View file

@ -0,0 +1,39 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
@Composable
fun CollectionFolderGeneric(
preferences: UserPreferences,
destination: Destination.MediaItem,
recursive: Boolean,
modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid(
preferences = preferences,
onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) },
destination = destination,
showTitle = showHeader,
recursive = recursive,
modifier =
modifier
.padding(start = 16.dp),
positionCallback = { columns, position ->
showHeader = position < columns
},
)
}

View file

@ -0,0 +1,173 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Tab
import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.rememberTab
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.RecommendedMovie
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun CollectionFolderMovie(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
val uiPrefs = preferences.appPreferences.interfacePreferences
val rememberedTabIndex =
if (uiPrefs.rememberSelectedTab) {
uiPrefs.getRememberedTabsOrDefault(destination.itemId.toString(), 0)
} else {
0
}
val tabs = listOf("Recommended", "Library")
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
if (uiPrefs.rememberSelectedTab) {
LaunchedEffect(selectedTabIndex) {
preferencesViewModel.preferenceDataStore.updateData {
preferences.appPreferences.rememberTab(destination.itemId, selectedTabIndex)
}
}
}
var showHeader by rememberSaveable { mutableStateOf(true) }
val onClickItem = { item: BaseItem ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
}
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
modifier = modifier,
) {
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
TabRow(
selectedTabIndex = focusTabIndex,
modifier =
Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
.focusRestorer(firstTabFocusRequester)
.onFocusChanged {
if (!it.isFocused) {
focusTabIndex = selectedTabIndex
}
},
indicator =
@Composable { tabPositions, doesTabRowHaveFocus ->
tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition ->
// TabRowDefaults.PillIndicator(
// currentTabPosition = currentTabPosition,
// doesTabRowHaveFocus = doesTabRowHaveFocus,
// )
TabRowDefaults.UnderlinedIndicator(
currentTabPosition = currentTabPosition,
doesTabRowHaveFocus = doesTabRowHaveFocus,
activeColor = MaterialTheme.colorScheme.border,
)
}
},
tabs = {
tabs.forEachIndexed { index, title ->
Tab(
selected = focusTabIndex == index,
onClick = { selectedTabIndex = index },
onFocus = { focusTabIndex = index },
colors =
TabDefaults.pillIndicatorTabColors(
focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.padding(8.dp)
.ifElse(
index == selectedTabIndex,
Modifier.focusRequester(firstTabFocusRequester),
),
)
}
}
},
)
}
when (selectedTabIndex) {
0 -> {
RecommendedMovie(
preferences = preferences,
onClickItem = onClickItem,
parentId = destination.itemId,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)
}
1 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = onClickItem,
destination = destination,
showTitle = false,
recursive = true,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
)
}
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)
}
}
}

View file

@ -0,0 +1,173 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Tab
import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.rememberTab
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun CollectionFolderTv(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
val uiPrefs = preferences.appPreferences.interfacePreferences
val rememberedTabIndex =
if (uiPrefs.rememberSelectedTab) {
uiPrefs.getRememberedTabsOrDefault(destination.itemId.toString(), 0)
} else {
0
}
val tabs = listOf("Recommended", "Library")
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
if (uiPrefs.rememberSelectedTab) {
LaunchedEffect(selectedTabIndex) {
preferencesViewModel.preferenceDataStore.updateData {
preferences.appPreferences.rememberTab(destination.itemId, selectedTabIndex)
}
}
}
val onClickItem = { item: BaseItem ->
preferencesViewModel.navigationManager.navigateTo(item.destination())
}
var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
modifier = modifier,
) {
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
TabRow(
selectedTabIndex = focusTabIndex,
modifier =
Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
.focusRestorer(firstTabFocusRequester)
.onFocusChanged {
if (!it.isFocused) {
focusTabIndex = selectedTabIndex
}
},
indicator =
@Composable { tabPositions, doesTabRowHaveFocus ->
tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition ->
// TabRowDefaults.PillIndicator(
// currentTabPosition = currentTabPosition,
// doesTabRowHaveFocus = doesTabRowHaveFocus,
// )
TabRowDefaults.UnderlinedIndicator(
currentTabPosition = currentTabPosition,
doesTabRowHaveFocus = doesTabRowHaveFocus,
activeColor = MaterialTheme.colorScheme.border,
)
}
},
tabs = {
tabs.forEachIndexed { index, title ->
Tab(
selected = focusTabIndex == index,
onClick = { selectedTabIndex = index },
onFocus = { focusTabIndex = index },
colors =
TabDefaults.pillIndicatorTabColors(
focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier =
Modifier
.padding(8.dp)
.ifElse(
index == selectedTabIndex,
Modifier.focusRequester(firstTabFocusRequester),
),
)
}
}
},
)
}
when (selectedTabIndex) {
0 -> {
RecommendedTvShow(
preferences = preferences,
parentId = destination.itemId,
onClickItem = onClickItem,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)
}
1 -> {
CollectionFolderGrid(
preferences = preferences,
destination = destination,
showTitle = false,
recursive = true,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
onClickItem = onClickItem,
)
}
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)
}
}
}

View file

@ -0,0 +1,90 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import java.util.UUID
/**
* Basic [ViewModel] for a single fetchable item from the API
*/
abstract class ItemViewModel(
val api: ApiClient,
) : ViewModel() {
val item = MutableLiveData<BaseItem?>(null)
suspend fun fetchItem(
itemId: UUID,
potential: BaseItem?,
): BaseItem =
withContext(Dispatchers.IO) {
// val fetchedItem =
// when {
// item.value == null && potential?.id == itemId -> potential
// item.value?.id == itemId -> item.value
// else -> {
// val it = api.userLibraryApi.getItem(itemId).content
// BaseItem.from(it, api)
// }
// }
val it = api.userLibraryApi.getItem(itemId).content
val fetchedItem = BaseItem.from(it, api)
return@withContext fetchedItem.let {
withContext(Dispatchers.Main) {
item.value = fetchedItem
}
fetchedItem
}
}
fun imageUrl(
itemId: UUID,
type: ImageType,
): String? = api.imageApi.getItemImageUrl(itemId, type)
}
/**
* Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred
*/
abstract class LoadingItemViewModel(
api: ApiClient,
) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
open fun init(
itemId: UUID,
potential: BaseItem?,
): Job? {
loading.value = LoadingState.Loading
return viewModelScope.launch(
LoadingExceptionHandler(
loading,
"Error loading item $itemId",
) + Dispatchers.IO,
) {
try {
val fetchedItem = api.userLibraryApi.getItem(itemId).content
withContext(Dispatchers.Main) {
item.value = BaseItem.from(fetchedItem, api)
loading.value = LoadingState.Success
}
} catch (e: Exception) {
Timber.e(e, "Failed to load item $itemId")
item.value = null
loading.value = LoadingState.Error("Error loading item $itemId", e)
}
}
}
}

View file

@ -0,0 +1,453 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
@HiltViewModel
class PlaylistViewModel
@Inject
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
) : ItemViewModel(api) {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val items = MutableLiveData<List<BaseItem?>>(listOf())
fun init(playlistId: UUID) {
loading.value = LoadingState.Loading
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"),
) {
val playlist = fetchItem(playlistId, null)
val request =
GetPlaylistItemsRequest(
playlistId = playlist.id,
fields = DefaultItemFields,
)
val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init()
withContext(Dispatchers.Main) {
items.value = pager
loading.value = LoadingState.Success
}
}
}
}
@Composable
fun PlaylistDetails(
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: PlaylistViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init(destination.itemId)
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val playlist by viewModel.item.observeAsState(null)
val items by viewModel.items.observeAsState(listOf())
var longClickDialog by remember { mutableStateOf<DialogParams?>(null) }
when (val st = loading) {
is LoadingState.Error -> ErrorMessage(st, modifier)
LoadingState.Pending, LoadingState.Loading -> LoadingPage(modifier)
LoadingState.Success ->
playlist?.let {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
PlaylistDetailsContent(
playlist = it,
items = items,
focusRequester = focusRequester,
onClickIndex = { index, _ ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it.id,
positionMs = 0L,
startIndex = index,
),
)
},
onClickPlay = { shuffle ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it.id,
positionMs = 0L,
startIndex = 0,
shuffle = shuffle,
),
)
},
onLongClickIndex = { index, item ->
longClickDialog =
DialogParams(
fromLongClick = true,
title = item.name ?: "",
items =
listOf(
DialogItem(
"Go to",
Icons.Default.ArrowForward,
) {
viewModel.navigationManager.navigateTo(item.destination())
},
DialogItem(
"Play from here",
Icons.Default.PlayArrow,
) {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = it.id,
positionMs = it.resumeMs ?: 0L,
startIndex = index,
),
)
},
),
)
},
modifier = modifier,
)
}
}
longClickDialog?.let { params ->
DialogPopup(
params = params,
onDismissRequest = { longClickDialog = null },
)
}
}
@Composable
fun PlaylistDetailsContent(
playlist: BaseItem,
items: List<BaseItem?>,
onClickIndex: (Int, BaseItem) -> Unit,
onLongClickIndex: (Int, BaseItem) -> Unit,
onClickPlay: (shuffle: Boolean) -> Unit,
modifier: Modifier = Modifier,
focusRequester: FocusRequester = remember { FocusRequester() },
) {
var savedIndex by rememberSaveable { mutableIntStateOf(0) }
var focusedIndex by remember { mutableIntStateOf(savedIndex) }
val focus = remember { FocusRequester() }
val focusedItem = items.getOrNull(focusedIndex)
val playButtonFocusRequester = remember { FocusRequester() }
Box(
modifier = modifier,
) {
if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = focusedItem.backdropImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopEnd,
modifier =
Modifier
.fillMaxHeight(.85f)
.alpha(.4f)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = 500f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(gradientColor, Color.Transparent),
endX = 400f,
startX = 100f,
),
)
},
)
}
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.padding(top = 16.dp)
.fillMaxSize(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(32.dp),
modifier =
Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
) {
PlaylistDetailsHeader(
focusedItem = focusedItem,
onClickPlay = onClickPlay,
playButtonFocusRequester = playButtonFocusRequester,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth(.25f),
)
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Text(
text = playlist.name ?: "Playlist",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displayMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn(
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.padding(bottom = 32.dp)
.fillMaxHeight()
// .fillMaxWidth(.8f)
.weight(1f)
.background(
MaterialTheme.colorScheme
.surfaceColorAtElevation(1.dp)
.copy(alpha = .75f),
shape = RoundedCornerShape(16.dp),
).focusRequester(focusRequester)
.focusGroup()
.focusRestorer(focus),
) {
itemsIndexed(items) { index, item ->
PlaylistItem(
item = item,
index = index,
onClick = {
savedIndex = index
item?.let {
onClickIndex.invoke(index, item)
}
},
onLongClick = {
savedIndex = index
item?.let {
onLongClickIndex.invoke(index, item)
}
},
modifier =
Modifier
.height(80.dp)
.ifElse(
index == savedIndex,
Modifier.focusRequester(focus),
).onFocusChanged {
if (it.isFocused) {
focusedIndex = index
}
}.focusProperties {
left = playButtonFocusRequester
previous = playButtonFocusRequester
},
)
}
}
}
}
}
}
}
@Composable
fun PlaylistDetailsHeader(
focusedItem: BaseItem?,
onClickPlay: (shuffle: Boolean) -> Unit,
playButtonFocusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.focusRequester(playButtonFocusRequester),
) {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { onClickPlay.invoke(false) },
)
ExpandableFaButton(
title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle,
onClick = { onClickPlay.invoke(true) },
)
}
Text(
text = focusedItem?.title ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineLarge,
)
Text(
text = focusedItem?.subtitle ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.headlineSmall,
)
OverviewText(
overview = focusedItem?.data?.overview ?: "",
maxLines = 10,
onClick = {},
enabled = false,
)
}
}
@Composable
fun PlaylistItem(
item: BaseItem?,
index: Int,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focused by interactionSource.collectIsFocusedAsState()
ListItem(
selected = false,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
headlineContent = {
Text(
text = item?.title ?: "",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.enableMarquee(focused),
)
},
supportingContent = {
Text(
text = item?.subtitle ?: "",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.enableMarquee(focused),
)
},
trailingContent = {
item?.data?.runTimeTicks?.ticks?.roundMinutes?.let {
Text(
text = it.toString(),
)
}
},
leadingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = "${index + 1}.",
style = MaterialTheme.typography.labelLarge,
)
ItemCardImage(
imageUrl = item?.imageUrl,
name = item?.name,
showOverlay = true,
favorite = item?.data?.userData?.isFavorite ?: false,
watched = item?.data?.userData?.played ?: false,
unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1,
watchedPercent = 0.0,
modifier = Modifier.width(160.dp),
useFallbackText = false,
)
}
},
modifier = modifier,
)
}

View file

@ -0,0 +1,416 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
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.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
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.ConfirmDialog
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.StarRating
import com.github.damontecres.wholphin.ui.components.StarRatingPrecision
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
@Composable
fun SeriesDetails(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init(preferences, destination.itemId, destination.item, null, null)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
val people by viewModel.people.observeAsState(listOf())
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> {
item?.let { item ->
val played = item.data.userData?.played ?: false
SeriesDetailsContent(
preferences = preferences,
series = item,
seasons = seasons,
people = people,
played = played,
modifier = modifier,
onClickItem = { viewModel.navigateTo(it.destination()) },
onLongClickItem = { season ->
seasonDialog =
buildDialogForSeason(
season,
onClickItem = { viewModel.navigateTo(it.destination()) },
markPlayed = { played ->
viewModel.setWatched(
season.id,
played,
null,
)
},
)
},
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(
title = item.name ?: "Unknown",
overview = item.data.overview,
files = listOf(),
)
},
playOnClick = { viewModel.playNextUp() },
watchOnClick = { showWatchConfirmation = true },
)
if (showWatchConfirmation) {
ConfirmDialog(
title = item.name ?: "",
body = if (played) "Mark entire series as unplayed?" else "Mark entire series as played?",
onCancel = {
showWatchConfirmation = false
},
onConfirm = {
viewModel.setWatchedSeries(!played)
showWatchConfirmation = false
},
)
}
}
}
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
onDismissRequest = { overviewDialog = null },
)
}
seasonDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
waitToLoad = params.fromLongClick,
onDismissRequest = { seasonDialog = null },
)
}
}
@Composable
fun SeriesDetailsContent(
preferences: UserPreferences,
series: BaseItem,
seasons: ItemListAndMapping,
people: List<Person>,
played: Boolean,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
overviewOnClick: () -> Unit,
playOnClick: () -> Unit,
watchOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var position by rememberPosition()
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Box(
modifier = modifier,
) {
if (series.backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = series.backdropImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopEnd,
modifier =
Modifier
.fillMaxHeight(.75f)
.alpha(.5f)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = size.height * .5f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(Color.Transparent, gradientColor),
endX = 0f,
startX = size.width * .75f,
),
)
},
)
}
Column(
modifier =
Modifier
.padding(16.dp)
.fillMaxSize(),
) {
LazyColumn(
contentPadding = PaddingValues(bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier,
) {
item {
SeriesDetailsHeader(
series = series,
played = played,
overviewOnClick = overviewOnClick,
playOnClick = playOnClick,
watchOnClick = watchOnClick,
modifier =
Modifier
.fillMaxWidth(.6f)
.bringIntoViewRequester(bringIntoViewRequester)
.padding(bottom = 80.dp)
.ifElse(position.row < 0, Modifier.focusRequester(focusRequester)),
)
}
item {
ItemRow(
title = "Seasons",
items = seasons.items,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
cardOnFocus = { isFocused, index ->
// if (isFocused) {
// scope.launch(ExceptionHandler()) {
// bringIntoViewRequester.bringIntoView()
// }
// }
},
modifier =
Modifier
.fillMaxWidth(),
cardContent = @Composable { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = {
position = RowColumn(0, index)
onClick.invoke()
},
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
imageWidth = Dp.Unspecified,
showImageOverlay = true,
modifier =
mod
.ifElse(
position.row == 0 && position.column == index,
Modifier.focusRequester(focusRequester),
),
)
},
)
}
if (people.isNotEmpty()) {
item {
PersonRow(
people = people,
onClick = {},
onLongClick = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
}
@Composable
fun SeriesDetailsHeader(
series: BaseItem,
played: Boolean,
overviewOnClick: () -> Unit,
playOnClick: () -> Unit,
watchOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val dto = series.data
val details =
buildList {
dto.productionYear?.let { add(it.toString()) }
dto.runTimeTicks
?.ticks
?.roundMinutes
?.let { add(it.toString()) }
dto.officialRating?.let(::add)
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = series.name ?: "Unknown",
style = MaterialTheme.typography.displaySmall,
modifier = Modifier.fillMaxWidth(),
)
DotSeparatedRow(
texts = details,
textStyle = MaterialTheme.typography.titleMedium,
)
dto.genres?.letNotEmpty {
Text(
text = it.joinToString(", "),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier,
)
}
dto.communityRating?.let {
if (it >= 0f) {
StarRating(
rating100 = (it * 10).toInt(),
onRatingChange = {},
enabled = false,
precision = StarRatingPrecision.HALF,
playSoundOnFocus = true,
modifier = Modifier.height(24.dp),
)
}
}
dto.overview?.let { overview ->
OverviewText(
overview = overview,
maxLines = 3,
onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(start = 16.dp),
) {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { playOnClick.invoke() },
modifier = Modifier,
)
ExpandableFaButton(
title = if (played) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (played) R.string.fa_eye else R.string.fa_eye_slash,
onClick = watchOnClick,
modifier = Modifier,
)
}
}
}
fun buildDialogForSeason(
s: BaseItem,
onClickItem: (BaseItem) -> Unit,
markPlayed: (Boolean) -> Unit,
): DialogParams {
val items =
buildList {
add(
DialogItem("Go to", Icons.Default.PlayArrow) {
onClickItem.invoke(s)
},
)
if (s.data.userData?.played == true) {
add(
DialogItem("Mark as unplayed", R.string.fa_eye) {
markPlayed.invoke(false)
},
)
} else {
add(
DialogItem("Mark as played", R.string.fa_eye_slash) {
markPlayed.invoke(true)
},
)
}
}
return DialogParams(
title = s.name ?: "Season",
fromLongClick = true,
items = items,
)
}

View file

@ -0,0 +1,350 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class SeriesViewModel
@Inject
constructor(
api: ApiClient,
@param:ApplicationContext val context: Context,
@param:AuthOkHttpClient private val okHttpClient: OkHttpClient,
private val navigationManager: NavigationManager,
) : ItemViewModel(api) {
private var player: Player? = null
private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val people = MutableLiveData<List<Person>>(listOf())
fun init(
prefs: UserPreferences,
itemId: UUID,
potential: BaseItem?,
season: Int?,
episode: Int?,
) {
this.seriesId = itemId
this.prefs = prefs
viewModelScope.launch(
LoadingExceptionHandler(
loading,
"Error loading series $seriesId",
) + Dispatchers.IO,
) {
val item = fetchItem(seriesId, potential)
val seasonsInfo = getSeasons(item)
// If a particular season was requested, fetch those episodes, otherwise get the first season
val episodeInfo =
(season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
?.let { seasonNum ->
loadEpisodesInternal(seasonNum)
} ?: EpisodeList.Error("Could not determine season")
withContext(Dispatchers.Main) {
seasons.value = seasonsInfo
episodes.value = episodeInfo
loading.value = LoadingState.Success
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.orEmpty()
}
maybePlayThemeSong(prefs.appPreferences.interfacePreferences.playThemeSongs)
}
}
/**
* If the series has a theme song & app settings allow, play it
*/
@OptIn(UnstableApi::class)
private fun maybePlayThemeSong(playThemeSongs: ThemeSongVolume) {
val volume =
when (playThemeSongs) {
ThemeSongVolume.UNRECOGNIZED,
ThemeSongVolume.DISABLED,
-> return
ThemeSongVolume.LOWEST -> .05f
ThemeSongVolume.LOW -> .1f
ThemeSongVolume.MEDIUM -> .25f
ThemeSongVolume.HIGH -> .5f
ThemeSongVolume.HIGHEST -> 75f
}
viewModelScope.launch(ExceptionHandler()) {
val themeSongs = api.libraryApi.getThemeSongs(seriesId).content
themeSongs.items.firstOrNull()?.let { theme ->
theme.mediaSources?.firstOrNull()?.let { source ->
val url =
api.universalAudioApi.getUniversalAudioStreamUrl(
theme.id,
container = listOf("opus", "mp3", "aaa", "flac"),
)
Timber.Forest.v("Found theme song for series $seriesId")
withContext(Dispatchers.Main) {
val player =
ExoPlayer
.Builder(context)
.setMediaSourceFactory(
DefaultMediaSourceFactory(
OkHttpDataSource.Factory(okHttpClient),
),
).build()
.apply {
this.volume = volume
playWhenReady = true
this@SeriesViewModel.player = this
}
addCloseable {
player.release()
}
player.setMediaItem(MediaItem.fromUri(url))
player.prepare()
}
}
}
}
}
fun release() {
player?.release()
player = null
}
private suspend fun getSeasons(item: BaseItem): ItemListAndMapping {
val request =
GetItemsRequest(
parentId = item.id,
recursive = false,
includeItemTypes = listOf(BaseItemKind.SEASON),
sortBy = listOf(ItemSortBy.INDEX_NUMBER),
sortOrder = listOf(SortOrder.ASCENDING),
fields =
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
ItemFields.SEASON_USER_DATA,
),
)
val pager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
)
pager.init()
Timber.Forest.v("Loaded ${pager.size} seasons for series ${item.id}")
val pairs =
pager.mapIndexed { index, _ ->
val season = pager.getBlocking(index)
Pair(season?.indexNumber!!, index)
}
val seasonNumToIndex = mapOf(*pairs.toTypedArray())
val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
}
private suspend fun loadEpisodesInternal(season: Int): EpisodeList {
val request =
GetEpisodesRequest(
seriesId = item.value!!.id,
season = season,
sortBy = ItemSortBy.INDEX_NUMBER,
fields =
listOf(
ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS,
ItemFields.OVERVIEW,
ItemFields.CUSTOM_RATING,
ItemFields.TRICKPLAY,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
),
)
val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope)
pager.init()
Timber.Forest.v("Loaded ${pager.size} episodes for season $season")
return EpisodeList.Success(convertPager(pager))
}
fun loadEpisodes(season: Int) {
this@SeriesViewModel.episodes.value = EpisodeList.Loading
viewModelScope.async(ExceptionHandler(true)) {
val episodes =
try {
loadEpisodesInternal(season)
} catch (e: Exception) {
Timber.e(e, "Error loading episodes for $seriesId for season $season")
EpisodeList.Error(e)
}
withContext(Dispatchers.Main) {
this@SeriesViewModel.episodes.value = episodes
}
}
}
fun setWatched(
itemId: UUID,
played: Boolean,
listIndex: Int?,
) = viewModelScope.launch(ExceptionHandler()) {
if (played) {
api.playStateApi.markPlayedItem(itemId)
} else {
api.playStateApi.markUnplayedItem(itemId)
}
listIndex?.let {
refreshEpisode(itemId, listIndex)
}
}
fun setWatchedSeries(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) {
api.playStateApi.markPlayedItem(seriesId)
} else {
api.playStateApi.markUnplayedItem(seriesId)
}
init(prefs, seriesId, null, null, null)
}
fun refreshEpisode(
itemId: UUID,
listIndex: Int,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val base = api.userLibraryApi.getItem(itemId).content
val item = BaseItem.Companion.from(base, api)
val eps = episodes.value!!
if (eps is EpisodeList.Success) {
val newItems =
eps.episodes.items.toMutableList().apply {
this[listIndex] = item
}
val newValue = EpisodeList.Success(eps.episodes.copy(items = newItems))
withContext(Dispatchers.Main) {
episodes.value = newValue
}
}
}
/**
* Play whichever episode is next up for series or else the first episode
*/
fun playNextUp() {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val result by api.tvShowsApi.getNextUp(seriesId = seriesId)
val nextUp =
result.items.firstOrNull() ?: api.tvShowsApi
.getEpisodes(
seriesId,
limit = 1,
).content.items
.firstOrNull()
if (nextUp != null) {
navigateTo(Destination.Playback(nextUp.id, 0L))
} else {
showToast(
context,
"Could not find an episode to play",
Toast.LENGTH_SHORT,
)
}
}
}
fun navigateTo(destination: Destination) {
release()
navigationManager.navigateTo(destination)
}
}
data class ItemListAndMapping(
val items: List<BaseItem?>,
val numberToIndex: Map<Int, Int>,
val indexToNumber: Map<Int, Int>,
) {
companion object {
fun empty() = ItemListAndMapping(listOf(), mapOf(), mapOf())
}
}
/**
* Calculate the index<->season/ep number pairings
*
* This allows for handling of missing seasons
*/
private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping {
val pairs =
pager.mapIndexed { index, _ ->
val item = pager.getBlocking(index)
Pair(item?.indexNumber ?: index, index)
}
val seasonNumToIndex = mapOf(*pairs.toTypedArray())
val indexToSeasonNum = mapOf(*pairs.map { Pair(it.second, it.first) }.toTypedArray())
return ItemListAndMapping(pager, seasonNumToIndex, indexToSeasonNum)
}
sealed interface EpisodeList {
data object Loading : EpisodeList
data class Error(
val message: String? = null,
val exception: Throwable? = null,
) : EpisodeList {
constructor(exception: Throwable) : this(null, exception)
}
data class Success(
val episodes: ItemListAndMapping,
) : EpisodeList
}

View file

@ -0,0 +1,107 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.movie.MovieViewModel
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.extensions.ticks
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class VideoViewModel
@Inject
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
) : LoadingItemViewModel(api)
@Composable
fun VideoDetails(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: MovieViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> {
item?.let { item ->
val dto = item.data
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(32.dp),
modifier = modifier,
) {
item {
Text(text = item.name ?: "Unknown")
}
dto.overview?.let {
item {
Text(text = it)
}
}
dto.userData?.playbackPositionTicks?.ticks?.let {
if (it > 60.seconds) {
item {
Button(
onClick = {
viewModel.navigationManager.navigateTo(
Destination.Playback(
item.id,
it.inWholeMilliseconds,
item,
),
)
},
) {
Text(text = "Resume")
}
}
}
}
item {
Button(
onClick = {
viewModel.navigationManager.navigateTo(
Destination.Playback(
item.id,
0L,
item,
),
)
},
) {
Text(text = "Play")
}
}
}
}
}
}
}

View file

@ -0,0 +1,334 @@
package com.github.damontecres.wholphin.ui.detail.movie
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
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.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.cards.ChapterRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
@HiltViewModel
class MovieViewModel
@Inject
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
) : LoadingItemViewModel(api) {
private lateinit var itemId: UUID
val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf())
override fun init(
itemId: UUID,
potential: BaseItem?,
): Job? {
this.itemId = itemId
return viewModelScope.launch(ExceptionHandler()) {
super.init(itemId, potential)?.join()
item.value?.let { item ->
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.orEmpty()
chapters.value = Chapter.fromDto(item.data, api)
}
}
}
fun setWatched(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) {
api.playStateApi.markPlayedItem(itemId)
} else {
api.playStateApi.markUnplayedItem(itemId)
}
init(itemId, null)
}
}
@Composable
fun MovieDetails(
preferences: UserPreferences,
destination: Destination.MediaItem,
modifier: Modifier = Modifier,
viewModel: MovieViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item)
}
val item by viewModel.item.observeAsState()
val people by viewModel.people.observeAsState(listOf())
val chapters by viewModel.chapters.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> {
item?.let { movie ->
MovieDetailsContent(
preferences = preferences,
movie = movie,
people = people,
chapters = chapters,
playOnClick = {
viewModel.navigationManager.navigateTo(
Destination.Playback(
movie.id,
it.inWholeMilliseconds,
movie,
),
)
},
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(
title = movie.name ?: "Unknown",
overview = movie.data.overview,
files =
movie.data.mediaSources
?.mapNotNull { it.path }
.orEmpty(),
)
},
moreOnClick = {
moreDialog =
DialogParams(
fromLongClick = false,
title = movie.name + " (${movie.data.productionYear ?: ""})",
items =
listOf(
DialogItem(
"Play",
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
viewModel.navigationManager.navigateTo(
Destination.Playback(movie),
)
},
// DialogItem(
// "Playback Settings",
// Icons.Default.Settings,
// // iconColor = Color.Green.copy(alpha = .8f),
// ) {
// // TODO choose audio or subtitle tracks?
// },
// DialogItem(
// "Play Version",
// Icons.Default.PlayArrow,
// iconColor = Color.Green.copy(alpha = .8f),
// ) {
// // TODO only show for multiple files
// },
),
)
},
watchOnClick = {
viewModel.setWatched((movie.data.userData?.played ?: false).not())
},
modifier = modifier,
)
}
}
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
onDismissRequest = { overviewDialog = null },
)
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
}
@Composable
fun MovieDetailsContent(
preferences: UserPreferences,
movie: BaseItem,
people: List<Person>,
chapters: List<Chapter>,
playOnClick: (Duration) -> Unit,
overviewOnClick: () -> Unit,
watchOnClick: () -> Unit,
moreOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
val dto = movie.data
val backdropImageUrl = movie.backdropImageUrl
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
val bringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(Unit) {
// bringIntoViewRequester.bringIntoView()
focusRequester.tryRequestFocus()
}
Box(modifier = modifier) {
if (backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = backdropImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopEnd,
modifier =
Modifier
.fillMaxHeight(.75f)
.alpha(.5f)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = size.height * .5f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(Color.Transparent, gradientColor),
endX = 0f,
startX = size.width * .75f,
),
)
},
)
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(32.dp),
modifier = Modifier.fillMaxSize(),
) {
item {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.bringIntoViewRequester(bringIntoViewRequester)
.padding(bottom = 56.dp),
) {
MovieDetailsHeader(
movie = movie,
bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick,
Modifier
.fillMaxWidth(.7f)
.padding(bottom = 8.dp),
)
ExpandablePlayButtons(
resumePosition = resumePosition,
watched = dto.userData?.played ?: false,
playOnClick = playOnClick,
moreOnClick = moreOnClick,
watchOnClick = watchOnClick,
buttonOnFocusChanged = {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
modifier = Modifier.focusRequester(focusRequester),
)
}
}
if (people.isNotEmpty()) {
item {
PersonRow(
people = people,
onClick = {},
onLongClick = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
if (chapters.isNotEmpty()) {
item {
ChapterRow(
chapters = chapters,
onClick = { playOnClick.invoke(it.position) },
onLongClick = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}

View file

@ -0,0 +1,192 @@
package com.github.damontecres.wholphin.ui.detail.movie
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.StarRating
import com.github.damontecres.wholphin.ui.components.StarRatingPrecision
import com.github.damontecres.wholphin.ui.components.TitleValueText
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.timeRemaining
import com.github.damontecres.wholphin.util.formatSubtitleLang
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks
@Composable
fun MovieDetailsHeader(
movie: BaseItem,
bringIntoViewRequester: BringIntoViewRequester,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val dto = movie.data
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
// Title
Text(
text = movie.name ?: "",
color = MaterialTheme.colorScheme.onSurface,
style =
MaterialTheme.typography.displayLarge.copy(
shadow =
Shadow(
color = Color.DarkGray,
offset = Offset(5f, 2f),
blurRadius = 2f,
),
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.alpha(0.75f),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val details =
buildList {
dto.productionYear?.let { add(it.toString()) }
val duration = dto.runTimeTicks?.ticks
duration
?.roundMinutes
?.toString()
?.let {
add(it)
}
dto.officialRating?.let(::add)
dto.timeRemaining?.roundMinutes?.let { add("$it left") }
}
DotSeparatedRow(
texts = details,
textStyle = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 8.dp),
)
}
dto.communityRating?.let {
if (it > 0f) {
StarRating(
rating100 = (it * 10).toInt(),
onRatingChange = {},
enabled = false,
precision = StarRatingPrecision.HALF,
playSoundOnFocus = true,
modifier = Modifier.height(32.dp),
)
} else {
Spacer(Modifier.height(32.dp))
}
}
// Description
dto.overview?.let { overview ->
OverviewText(
overview = overview,
maxLines = 3,
onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified,
)
}
movie.data.people
?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() }
?.joinToString(", ") { it.name!! }
?.let {
Text(
text = "Directed by $it",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
// Key-Values
Row(
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
dto.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.displayTitle?.let {
TitleValueText(
stringResource(R.string.video),
it,
modifier = Modifier.widthIn(max = 200.dp),
)
}
dto.mediaStreams
?.firstOrNull { it.type == MediaStreamType.AUDIO }
?.displayTitle
?.let {
// TODO probably a cleaner way to do this
// Removes part of "5.1 Surround - English - AAC - Default"
it
.replace(" - Default", "")
.ifBlank { null }
?.let {
TitleValueText(
stringResource(R.string.audio),
it,
modifier = Modifier.widthIn(max = 200.dp),
)
}
}
formatSubtitleLang(dto.mediaStreams)
?.let {
if (it.isNotNullOrBlank()) {
TitleValueText(
"Subtitles",
it,
modifier = Modifier.widthIn(max = 120.dp),
)
}
}
// TODO add writers, studio, etc to overview dialog
// dto.studios?.letNotEmpty {
// TitleValueText(
// stringResource(R.string.studios),
// it.joinToString(", ") { s -> s.name ?: "" },
// modifier = Modifier.widthIn(max = 80.dp),
// )
// }
// dto.genres?.letNotEmpty {
// TitleValueText(
// stringResource(R.string.genres),
// it.joinToString(", "),
// modifier = Modifier.widthIn(max = 80.dp),
// )
// }
}
}
}
}

View file

@ -0,0 +1,91 @@
package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
import com.github.damontecres.wholphin.ui.components.TitleValueText
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.util.formatSubtitleLang
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration
@Composable
fun FocusedEpisodeFooter(
ep: BaseItem,
playOnClick: (Duration) -> Unit,
moreOnClick: () -> Unit,
watchOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val dto = ep.data
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
val firstFocus = remember { FocusRequester() }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
ExpandablePlayButtons(
resumePosition = resumePosition,
watched = dto.userData?.played ?: false,
playOnClick = playOnClick,
moreOnClick = moreOnClick,
watchOnClick = watchOnClick,
buttonOnFocusChanged = {},
modifier = Modifier,
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
dto.mediaStreams
?.firstOrNull { it.type == MediaStreamType.VIDEO }
?.displayTitle
?.let {
TitleValueText(
stringResource(R.string.video),
it,
)
}
dto.mediaStreams
?.firstOrNull { it.type == MediaStreamType.AUDIO }
?.displayTitle
?.let {
// TODO probably a cleaner way to do this
// Removes part of "5.1 Surround - English - AAC - Default"
it
.replace(" - Default", "")
.ifBlank { null }
?.let {
TitleValueText(
stringResource(R.string.audio),
it,
)
}
}
formatSubtitleLang(dto.mediaStreams)
?.let {
if (it.isNotNullOrBlank()) {
TitleValueText(
"Subtitles",
it,
modifier = Modifier.widthIn(max = 64.dp),
)
}
}
}
}
}

View file

@ -0,0 +1,93 @@
package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.StarRating
import com.github.damontecres.wholphin.ui.components.StarRatingPrecision
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.timeRemaining
import com.github.damontecres.wholphin.util.formatDateTime
import com.github.damontecres.wholphin.util.seasonEpisode
import org.jellyfin.sdk.model.extensions.ticks
@Composable
fun FocusedEpisodeHeader(
ep: BaseItem,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val dto = ep.data
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Text(
text = dto.episodeTitle ?: dto.name ?: "",
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val details =
buildList {
dto.seasonEpisode?.let(::add)
dto.premiereDate?.let { add(formatDateTime(it)) }
val duration = dto.runTimeTicks?.ticks
duration
?.roundMinutes
?.toString()
?.let {
add(it)
}
dto.timeRemaining?.roundMinutes?.let { add("$it left") }
dto.officialRating?.let(::add)
}
DotSeparatedRow(
texts = details,
textStyle = MaterialTheme.typography.titleSmall,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
dto.communityRating?.let {
if (it > 0f) {
StarRating(
rating100 = (it * 10).toInt(),
onRatingChange = {},
enabled = false,
precision = StarRatingPrecision.HALF,
playSoundOnFocus = true,
modifier = Modifier.height(24.dp),
)
} else {
Spacer(Modifier.height(24.dp))
}
} ?: Spacer(Modifier.height(24.dp))
}
OverviewText(
overview = dto.overview ?: "",
maxLines = 3,
onClick = overviewOnClick,
)
}
}

View file

@ -0,0 +1,272 @@
package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.EpisodeList
import com.github.damontecres.wholphin.ui.detail.ItemListAndMapping
import com.github.damontecres.wholphin.ui.detail.SeriesViewModel
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import kotlin.time.Duration
@Serializable
data class SeasonEpisode(
val season: Int,
val episode: Int,
)
@Serializable
data class SeriesOverviewPosition(
val seasonTabIndex: Int,
val episodeRowIndex: Int,
)
@Composable
fun SeriesOverview(
preferences: UserPreferences,
destination: Destination.SeriesOverview,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel(),
initialSeasonEpisode: SeasonEpisode? = null,
) {
val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() }
OneTimeLaunchedEffect {
Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode")
viewModel.init(
preferences,
destination.itemId,
destination.item,
initialSeasonEpisode?.season,
initialSeasonEpisode?.episode,
)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val series by viewModel.item.observeAsState(null)
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading)
val episodeList = (episodes as? EpisodeList.Success)?.episodes?.items
var position by rememberSaveable(
destination,
loading,
stateSaver =
Saver(
save = { listOf(it.seasonTabIndex, it.episodeRowIndex) },
restore = { SeriesOverviewPosition(it[0], it[1]) },
),
) {
mutableStateOf(
SeriesOverviewPosition(
seasons.numberToIndex[initialSeasonEpisode?.season ?: 0] ?: 0,
(episodes as? EpisodeList.Success)?.episodes?.numberToIndex[
initialSeasonEpisode?.episode
?: 0,
] ?: 0,
),
)
}
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
LaunchedEffect(episodes) {
episodes?.let { episodes ->
if (episodes is EpisodeList.Success) {
if (episodes.episodes.items.isNotEmpty()) {
// TODO focus on first episode when changing seasons?
// firstItemFocusRequester.requestFocus()
episodes.episodes.items.getOrNull(position.episodeRowIndex)?.let {
viewModel.refreshEpisode(it.id, position.episodeRowIndex)
}
}
}
}
}
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> {
series?.let { series ->
LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() }
SeriesOverviewContent(
series = series,
seasons = seasons.items,
episodes = episodes,
position = position,
backdropImageUrl =
remember {
viewModel.imageUrl(
series.id,
ImageType.BACKDROP,
)
},
firstItemFocusRequester = firstItemFocusRequester,
episodeRowFocusRequester = episodeRowFocusRequester,
onFocus = {
if (it.seasonTabIndex != position.seasonTabIndex) {
seasons.indexToNumber[it.seasonTabIndex]?.let { seasonNumber ->
viewModel.loadEpisodes(seasonNumber)
}
}
position = it
},
onClick = {
val resumePosition =
it.data.userData
?.playbackPositionTicks
?.ticks ?: Duration.ZERO
viewModel.navigateTo(
Destination.Playback(
it.id,
resumePosition.inWholeMilliseconds,
it,
),
)
},
onLongClick = {
// TODO
},
playOnClick = { resume ->
episodeList?.getOrNull(position.episodeRowIndex)?.let {
viewModel.release()
viewModel.navigateTo(
Destination.Playback(
it.id,
resume.inWholeMilliseconds,
it,
),
)
}
},
watchOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
val played = it.data.userData?.played ?: false
viewModel.setWatched(it.id, !played, position.episodeRowIndex)
}
},
moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
moreDialog =
DialogParams(
fromLongClick = false,
title = series.name + " - " + ep.data.seasonEpisode,
items =
listOf(
DialogItem(
"Play",
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
viewModel.navigateTo(
Destination.Playback(
ep.id,
ep.resumeMs ?: 0L,
ep,
),
)
},
DialogItem(
"Go to series",
Icons.AutoMirrored.Filled.ArrowForward,
// iconColor = Color.Green.copy(alpha = .8f),
) {
viewModel.navigateTo(
Destination.MediaItem(
series.id,
BaseItemKind.SERIES,
series,
),
)
},
// DialogItem(
// "Playback Settings",
// Icons.Default.Settings,
// // iconColor = Color.Green.copy(alpha = .8f),
// ) {
// // TODO choose audio or subtitle tracks?
// },
// DialogItem(
// "Play Version",
// Icons.Default.PlayArrow,
// iconColor = Color.Green.copy(alpha = .8f),
// ) {
// // TODO only show for multiple files
// },
),
)
}
},
overviewOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
overviewDialog =
ItemDetailsDialogInfo(
title = it.name ?: "Unknown",
overview = it.data.overview,
files =
it.data.mediaSources
?.mapNotNull { it.path }
.orEmpty(),
)
}
},
modifier = modifier,
)
}
}
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
onDismissRequest = { overviewDialog = null },
)
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
}

View file

@ -0,0 +1,296 @@
package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Tab
import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.EpisodeList
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.formatDateTime
import kotlin.time.Duration
@Composable
fun SeriesOverviewContent(
series: BaseItem,
seasons: List<BaseItem?>,
episodes: EpisodeList,
position: SeriesOverviewPosition,
backdropImageUrl: String?,
firstItemFocusRequester: FocusRequester,
episodeRowFocusRequester: FocusRequester,
onFocus: (SeriesOverviewPosition) -> Unit,
onClick: (BaseItem) -> Unit,
onLongClick: (BaseItem) -> Unit,
playOnClick: (Duration) -> Unit,
watchOnClick: () -> Unit,
moreOnClick: () -> Unit,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var focusTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) }
var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) }
val focusRequesters = remember(seasons.size) { List(seasons.size) { FocusRequester() } }
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
val tabRowFocusRequester = remember { FocusRequester() }
val focusedEpisode =
(episodes as? EpisodeList.Success)?.episodes?.items?.getOrNull(position.episodeRowIndex)
Box(
modifier =
modifier
.fillMaxWidth()
// .fillMaxHeight(.33f)
.height(460.dp)
.bringIntoViewRequester(bringIntoViewRequester),
) {
if (backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = backdropImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopEnd,
modifier =
Modifier
.fillMaxHeight(.6f)
.alpha(.4f)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = 500f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(gradientColor, Color.Transparent),
endX = 400f,
startX = 100f,
),
)
},
)
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier,
) {
item {
TabRow(
selectedTabIndex = focusTabIndex,
modifier =
Modifier
.ifElse(
focusRequesters.size > selectedTabIndex,
{ Modifier.focusRestorer(focusRequesters[selectedTabIndex]) },
).focusRequester(tabRowFocusRequester)
.padding(horizontal = 16.dp)
.fillMaxWidth()
.onFocusChanged {
if (!it.isFocused) {
focusTabIndex = selectedTabIndex
}
},
indicator =
@Composable { tabPositions, doesTabRowHaveFocus ->
tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition ->
// TabRowDefaults.PillIndicator(
// currentTabPosition = currentTabPosition,
// doesTabRowHaveFocus = doesTabRowHaveFocus,
// )
TabRowDefaults.UnderlinedIndicator(
currentTabPosition = currentTabPosition,
doesTabRowHaveFocus = doesTabRowHaveFocus,
activeColor = MaterialTheme.colorScheme.border,
)
}
},
) {
seasons.forEachIndexed { index, season ->
season?.let { season ->
Tab(
selected = focusTabIndex == index,
onFocus = { focusTabIndex = index },
onClick = {
selectedTabIndex = index
onFocus.invoke(SeriesOverviewPosition(index, 0))
},
colors =
TabDefaults.pillIndicatorTabColors(
focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
modifier =
Modifier
.focusRequester(focusRequesters[index]),
) {
Text(
text = season.name ?: "Season ${season.data.indexNumber}",
modifier = Modifier.padding(8.dp),
)
}
}
}
}
}
item {
series.name?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier,
)
}
}
item {
// Episode header
focusedEpisode?.let { ep ->
FocusedEpisodeHeader(
ep = ep,
overviewOnClick = overviewOnClick,
modifier = Modifier.fillMaxWidth(.66f),
)
}
}
item {
key(position.seasonTabIndex) {
when (val eps = episodes) {
EpisodeList.Loading -> LoadingPage()
is EpisodeList.Error -> ErrorMessage(eps.message, eps.exception)
is EpisodeList.Success -> {
val state = rememberLazyListState()
OneTimeLaunchedEffect {
if (state.firstVisibleItemIndex != position.episodeRowIndex) {
state.scrollToItem(position.episodeRowIndex)
}
firstItemFocusRequester.tryRequestFocus()
}
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
modifier =
Modifier
.focusRestorer(firstItemFocusRequester)
.focusRequester(episodeRowFocusRequester),
) {
itemsIndexed(eps.episodes.items) { episodeIndex, episode ->
val interactionSource = remember { MutableInteractionSource() }
if (interactionSource.collectIsFocusedAsState().value) {
onFocus.invoke(
SeriesOverviewPosition(
selectedTabIndex,
episodeIndex,
),
)
}
val cornerText =
episode?.data?.indexNumber?.let { "E$it" }
?: episode?.data?.premiereDate?.let(::formatDateTime)
BannerCard(
name = episode?.name,
imageUrl = episode?.imageUrl,
aspectRatio =
episode
?.data
?.primaryImageAspectRatio
?.toFloat()
?.coerceAtLeast(
episode.data.aspectRatioFloat ?: (4f / 3f),
)
?: (16f / 9), // TODO some episode images don't match the file's aspect ratio
cornerText = cornerText,
played = episode?.data?.userData?.played ?: false,
playPercent =
episode?.data?.userData?.playedPercentage
?: 0.0,
onClick = { if (episode != null) onClick.invoke(episode) },
onLongClick = {
if (episode != null) {
onLongClick.invoke(
episode,
)
}
},
modifier =
Modifier.ifElse(
episodeIndex == position.episodeRowIndex,
Modifier.focusRequester(firstItemFocusRequester),
),
interactionSource = interactionSource,
cardHeight = 120.dp,
)
}
}
}
}
}
}
item {
focusedEpisode?.let { ep ->
FocusedEpisodeFooter(
ep = ep,
playOnClick = playOnClick,
moreOnClick = moreOnClick,
watchOnClick = watchOnClick,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 16.dp),
)
}
}
}
}
}

View file

@ -0,0 +1,297 @@
package com.github.damontecres.wholphin.ui.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.RowColumnSaver
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.timeRemaining
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.formatDateTime
import com.github.damontecres.wholphin.util.seasonEpisode
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks
data class HomeRow(
val section: HomeSection,
val items: List<BaseItem?>,
val title: String? = null,
)
@Composable
fun HomePage(
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = hiltViewModel(),
) {
LaunchedEffect(Unit) {
viewModel.init(preferences)
}
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> {
val homeRows by viewModel.homeRows.observeAsState(listOf())
HomePageContent(
homeRows,
onClickItem = {
viewModel.navigationManager.navigateTo(it.destination())
},
modifier,
)
}
}
}
@Composable
fun HomePageContent(
homeRows: List<HomeRow>,
onClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
) {
var position by rememberSaveable(stateSaver = RowColumnSaver) {
mutableStateOf(RowColumn(0, 0))
}
var focusedItem = position.let { homeRows.getOrNull(it.row)?.items?.getOrNull(it.column) }
val focusRequester = remember { FocusRequester() }
val positionFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
positionFocusRequester.tryRequestFocus()
}
Box(modifier = modifier) {
if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = focusedItem?.backdropImageUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
modifier =
Modifier
.fillMaxHeight(.7f)
.fillMaxWidth(.7f)
.alpha(.75f)
.align(Alignment.TopEnd)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = size.height * .33f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(gradientColor, Color.Transparent),
startX = 0f,
endX = size.width * .5f,
),
)
},
)
}
Column(modifier = Modifier.fillMaxSize()) {
MainPageHeader(
item = focusedItem,
modifier =
Modifier
.fillMaxWidth(.6f)
.fillMaxHeight(.33f)
.padding(16.dp),
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding =
PaddingValues(
start = 16.dp,
end = 16.dp,
top = 0.dp,
bottom = 48.dp,
),
modifier = Modifier,
) {
itemsIndexed(homeRows) { rowIndex, row ->
if (row.items.isNotEmpty()) {
ItemRow(
title = row.title ?: stringResource(row.section.nameRes),
items = row.items,
onClickItem = onClickItem,
cardOnFocus = { isFocused, index ->
if (isFocused) {
focusedItem = row.items.getOrNull(index)
position = RowColumn(rowIndex, index)
}
},
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),
),
interactionSource = null,
cardHeight = Cards.height2x3,
)
},
)
}
}
}
}
}
}
@Composable
fun MainPageHeader(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(),
) {
item?.let {
val dto = item.data
val isEpisode = item.type == BaseItemKind.EPISODE
val title = if (isEpisode) dto.seriesName ?: item.name else item.name
val subtitle = if (isEpisode) dto.name else null
val overview = dto.overview
val details =
buildList {
if (isEpisode) {
val se = dto.seasonEpisode
if (se != null) {
add(se)
} else if (dto.parentIndexNumber != null) {
// Maybe a daily episode, so just show season, the date is added below
add("S${dto.parentIndexNumber}")
}
}
if (isEpisode) {
dto.premiereDate?.let { add(formatDateTime(it)) }
} else {
dto.productionYear?.let { add(it.toString()) }
}
dto.runTimeTicks?.ticks?.roundMinutes?.let {
add(it.toString())
}
dto.timeRemaining?.roundMinutes?.let {
add("$it left")
}
}
title?.let {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
subtitle?.let {
Text(
text = subtitle,
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (details.isNotEmpty()) {
DotSeparatedRow(
texts = details,
textStyle = MaterialTheme.typography.bodyLarge,
modifier = Modifier,
)
}
val overviewModifier =
Modifier
.padding(0.dp)
.height(48.dp + if (!isEpisode) 12.dp else 0.dp)
if (overview.isNotNullOrBlank()) {
Text(
text = overview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (isEpisode) 2 else 3,
overflow = TextOverflow.Ellipsis,
modifier = overviewModifier,
)
} else {
Spacer(overviewModifier)
}
}
}
}
}

View file

@ -0,0 +1,30 @@
package com.github.damontecres.wholphin.ui.main
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R
/**
* All possible homesections, "synced" with jellyfin-web.
*
* https://github.com/jellyfin/jellyfin-web/blob/master/src/components/homesections/homesections.js
*/
enum class HomeSection(
val key: String,
@param:StringRes val nameRes: Int,
) {
LATEST_MEDIA("latestmedia", R.string.home_section_latest_media),
LIBRARY_TILES_SMALL("smalllibrarytiles", R.string.home_section_library),
LIBRARY_BUTTONS("librarybuttons", R.string.home_section_library_small),
RESUME("resume", R.string.home_section_resume),
RESUME_AUDIO("resumeaudio", R.string.home_section_resume_audio),
RESUME_BOOK("resumebook", R.string.home_section_resume_book),
ACTIVE_RECORDINGS("activerecordings", R.string.home_section_active_recordings),
NEXT_UP("nextup", R.string.home_section_next_up),
LIVE_TV("livetv", R.string.home_section_livetv),
NONE("none", R.string.home_section_none),
;
companion object {
fun fromKey(key: String): HomeSection = entries.firstOrNull { it.key == key } ?: NONE
}
}

View file

@ -0,0 +1,199 @@
package com.github.damontecres.wholphin.ui.main
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.supportItemKinds
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class HomeViewModel
@Inject
constructor(
val api: ApiClient,
val navigationManager: NavigationManager,
) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Loading)
val homeRows = MutableLiveData<List<HomeRow>>()
fun init(preferences: UserPreferences) {
val prefs = preferences.appPreferences.homePagePreferences
val limit = prefs.maxItemsPerRow
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loadingState,
"Error loading home page",
),
) {
Timber.d("init HomeViewModel")
val user by api.userApi.getCurrentUser()
// val displayPrefs =
// api.displayPreferencesApi
// .getDisplayPreferences(
// displayPreferencesId = "usersettings",
// client = "emby",
// ).content
val homeSections =
listOf(
HomeSection.RESUME,
HomeSection.NEXT_UP,
HomeSection.LATEST_MEDIA,
)
// TODO use display preferences?
// displayPrefs.customPrefs.entries
// .filter { it.key.startsWith("homesection") && it.value.isNotNullOrBlank() }
// .sortedBy { it.key }
// .map { HomeSection.fromKey(it.value ?: "") }
// .filter {
// it in
// setOf(
// HomeSection.LATEST_MEDIA,
// HomeSection.NEXT_UP,
// HomeSection.RESUME,
// )
// }
// TODO data is fetched all together which may be slow for large servers
val resume = getResume(user.id, limit)
val nextUp = getNextUp(user.id, limit, prefs.enableRewatchingNextUp)
val latest = getLatest(user, limit)
val homeRows =
if (prefs.combineContinueNext) {
listOf(
HomeRow(
section = HomeSection.NEXT_UP,
items = resume + nextUp,
),
*latest.toTypedArray(),
)
} else {
listOf(
HomeRow(
section = HomeSection.RESUME,
items = resume,
),
HomeRow(
section = HomeSection.NEXT_UP,
items = nextUp,
),
*latest.toTypedArray(),
)
}
withContext(Dispatchers.Main) {
this@HomeViewModel.homeRows.value = homeRows
loadingState.value = LoadingState.Success
}
}
}
private suspend fun getResume(
userId: UUID,
limit: Int,
): List<BaseItem> {
val request =
GetResumeItemsRequest(
userId = userId,
fields = DefaultItemFields,
limit = limit,
includeItemTypes = supportItemKinds,
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { BaseItem.Companion.from(it, api, true) }
return items
}
private suspend fun getNextUp(
userId: UUID,
limit: Int,
enableRewatching: Boolean,
): List<BaseItem> {
val request =
GetNextUpRequest(
userId = userId,
fields = DefaultItemFields,
imageTypeLimit = 1,
parentId = null,
limit = limit,
enableResumable = false,
enableUserData = true,
enableRewatching = enableRewatching,
)
val nextUp =
api.tvShowsApi
.getNextUp(request)
.content
.items
.map { BaseItem.Companion.from(it, api, true) }
return nextUp
}
private suspend fun getLatest(
user: UserDto,
limit: Int,
): List<HomeRow> {
val latestMediaIncludes =
user.configuration?.orderedViews.orEmpty().toMutableList().apply {
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
}
val views by api.userViewsApi.getUserViews()
val rows =
latestMediaIncludes
.mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } }
.filter { it.collectionType in supportedCollectionTypes }
.map { view ->
val title =
view.name?.let { "Recently Added in $it" }
val request =
GetLatestMediaRequest(
fields = DefaultItemFields,
imageTypeLimit = 1,
parentId = view.id,
groupItems = true,
limit = limit,
isPlayed = null, // Server will handle user's preference
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.from(it, api, true) }
HomeRow(
section = HomeSection.LATEST_MEDIA,
items = latest,
title = title,
)
}
return rows
}
}

View file

@ -0,0 +1,273 @@
package com.github.damontecres.wholphin.ui.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.cards.EpisodeCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import javax.inject.Inject
@HiltViewModel
class SearchViewModel
@Inject
constructor(
val api: ApiClient,
val navigationManager: NavigationManager,
) : ViewModel() {
val movies = MutableLiveData<List<BaseItem?>>(listOf())
val series = MutableLiveData<List<BaseItem?>>(listOf())
val episodes = MutableLiveData<List<BaseItem?>>(listOf())
private var currentQuery: String? = null
fun search(query: String?) {
if (currentQuery == query) {
return
}
currentQuery = query
movies.value = listOf()
series.value = listOf()
episodes.value = listOf()
if (query.isNotNullOrBlank()) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val request =
GetItemsRequest(
searchTerm = query,
recursive = true,
includeItemTypes = listOf(BaseItemKind.MOVIE),
fields = DefaultItemFields,
limit = 25,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
pager.init()
withContext(Dispatchers.Main) {
movies.value = pager
}
}
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val request =
GetItemsRequest(
searchTerm = query,
recursive = true,
includeItemTypes = listOf(BaseItemKind.SERIES),
fields = DefaultItemFields,
limit = 25,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
pager.init()
withContext(Dispatchers.Main) {
series.value = pager
}
}
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val request =
GetItemsRequest(
searchTerm = query,
recursive = true,
includeItemTypes = listOf(BaseItemKind.EPISODE),
fields = DefaultItemFields,
limit = 25,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
pager.init()
withContext(Dispatchers.Main) {
episodes.value = pager
}
}
}
}
fun getHints(query: String) {
// TODO
// api.searchApi.getSearchHints()
}
}
private const val MOVIE_ROW = 0
private const val SERIES_ROW = 1
private const val EPISODE_ROW = 2
@Composable
fun SearchPage(
userPreferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: SearchViewModel = hiltViewModel(),
) {
val movies by viewModel.movies.observeAsState(listOf())
val series by viewModel.series.observeAsState(listOf())
val episodes by viewModel.episodes.observeAsState(listOf())
var query by rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
var position by rememberPosition()
LaunchedEffect(query) {
delay(750L)
viewModel.search(query)
}
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth(),
) {
SearchEditTextBox(
value = query,
onValueChange = {
query = it
},
onSearchClick = {
viewModel.search(query)
},
modifier =
Modifier.ifElse(
position.row < MOVIE_ROW,
Modifier.focusRequester(focusRequester),
),
)
}
}
if (movies.isNotEmpty()) {
item {
ItemRow(
title = "Movies",
items = movies,
onClickItem = {
viewModel.navigationManager.navigateTo(it.destination())
},
onLongClickItem = {},
modifier = Modifier,
cardContent = @Composable { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = {
position = RowColumn(MOVIE_ROW, index)
onClick.invoke()
},
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
modifier =
mod
.ifElse(
position.row == MOVIE_ROW && position.column == index,
Modifier.focusRequester(focusRequester),
),
)
},
)
}
}
if (series.isNotEmpty()) {
item {
ItemRow(
title = "Series",
items = series,
onClickItem = {
viewModel.navigationManager.navigateTo(it.destination())
},
onLongClickItem = {},
modifier = Modifier,
cardContent = @Composable { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = {
position = RowColumn(SERIES_ROW, index)
onClick.invoke()
},
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
modifier =
mod.ifElse(
position.row == SERIES_ROW && position.column == index,
Modifier.focusRequester(focusRequester),
),
)
},
)
}
}
if (episodes.isNotEmpty()) {
item {
ItemRow(
title = "Episodes",
items = episodes,
onClickItem = {
viewModel.navigationManager.navigateTo(it.destination())
},
onLongClickItem = {},
modifier = Modifier,
cardContent = @Composable { index, item, mod, onClick, onLongClick ->
EpisodeCard(
item = item,
onClick = {
position = RowColumn(EPISODE_ROW, index)
onClick.invoke()
},
onLongClick = onLongClick,
imageHeight = 140.dp,
modifier =
mod
.padding(horizontal = 8.dp)
.ifElse(
position.row == EPISODE_ROW && position.column == index,
Modifier.focusRequester(focusRequester),
),
)
},
)
}
}
}
}

View file

@ -0,0 +1,63 @@
package com.github.damontecres.wholphin.ui.nav
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.github.damontecres.wholphin.data.JellyfinServer
import com.github.damontecres.wholphin.data.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences
import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber
/**
* This is generally the root composable of the of the app
*
* Here the navigation backstack is used and pages are rendered in the nav drawer or full screen
*/
@Composable
fun ApplicationContent(
server: JellyfinServer?,
user: JellyfinUser?,
navigationManager: NavigationManager,
preferences: UserPreferences,
deviceProfile: DeviceProfile,
modifier: Modifier = Modifier,
) {
NavDisplay(
backStack = navigationManager.backStack,
onBack = { navigationManager.goBack() },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { key ->
key as Destination
val contentKey = "${key}_${server?.id}_${user?.id}"
Timber.d("Navigate: %s", key)
NavEntry(key, contentKey = contentKey) {
if (key.fullScreen) {
DestinationContent(
destination = key,
preferences = preferences,
deviceProfile = deviceProfile,
modifier = modifier.fillMaxSize(),
)
} else {
NavDrawer(
destination = key,
preferences = preferences,
deviceProfile = deviceProfile,
user = user,
server = server,
modifier = modifier,
)
}
}
},
)
}

View file

@ -0,0 +1,83 @@
@file:UseSerializers(UuidSerializer::class)
package com.github.damontecres.wholphin.ui.nav
import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.util.UuidSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID
/**
* Represents a page in the app
*
* @param fullScreen whether the page should be full page aka not include the nav drawer
*/
@Serializable
sealed class Destination(
val fullScreen: Boolean = false,
) : NavKey {
@Serializable
data object ServerList : Destination(true)
@Serializable
data object UserList : Destination(true)
@Serializable
data class Home(
val id: Long = 0L,
) : Destination()
@Serializable
data class Settings(
val screen: PreferenceScreenOption,
) : Destination(true)
@Serializable
data object Search : Destination()
@Serializable
data class SeriesOverview(
val itemId: UUID,
val type: BaseItemKind,
@Transient val item: BaseItem? = null,
val seasonEpisode: SeasonEpisode? = null,
) : Destination() {
override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)"
}
@Serializable
data class MediaItem(
val itemId: UUID,
val type: BaseItemKind,
@Transient val item: BaseItem? = null,
val seasonEpisode: SeasonEpisode? = null,
) : Destination() {
override fun toString(): String =
"MediaItem(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode, collectionType=${item?.data?.collectionType})"
}
@Serializable
data class Playback(
val itemId: UUID,
val positionMs: Long,
@Transient val item: BaseItem? = null,
val startIndex: Int? = null,
val shuffle: Boolean = false,
) : Destination(true) {
override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)"
constructor(item: BaseItem) : this(item.id, item.resumeMs ?: 0, item)
}
@Serializable
data object UpdateApp : Destination(true)
@Serializable
data object License : Destination(true)
}

View file

@ -0,0 +1,164 @@
package com.github.damontecres.wholphin.ui.nav
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.LicenseInfo
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
import com.github.damontecres.wholphin.ui.detail.SeriesDetails
import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails
import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.main.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile
/**
* Chose the page for the [Destination]
*/
@Composable
fun DestinationContent(
destination: Destination,
preferences: UserPreferences,
deviceProfile: DeviceProfile,
modifier: Modifier = Modifier,
) {
when (destination) {
is Destination.Home ->
HomePage(
preferences = preferences,
modifier = modifier,
)
is Destination.Playback ->
PlaybackPage(
preferences = preferences,
deviceProfile = deviceProfile,
destination = destination,
modifier = modifier,
)
Destination.ServerList -> SwitchServerContent(modifier)
Destination.UserList -> SwitchUserContent(modifier)
is Destination.Settings ->
PreferencesPage(
preferences.appPreferences,
destination.screen,
modifier,
)
is Destination.SeriesOverview ->
SeriesOverview(
preferences,
destination,
modifier,
initialSeasonEpisode = destination.seasonEpisode,
)
is Destination.MediaItem ->
when (destination.type) {
BaseItemKind.SERIES ->
SeriesDetails(
preferences,
destination,
modifier,
)
BaseItemKind.MOVIE ->
MovieDetails(
preferences,
destination,
modifier,
)
BaseItemKind.VIDEO ->
// TODO Use VideoDetails
MovieDetails(
preferences,
destination,
modifier,
)
BaseItemKind.BOX_SET ->
CollectionFolderGeneric(
preferences,
destination,
false,
modifier,
)
BaseItemKind.COLLECTION_FOLDER -> {
when (destination.item?.data?.collectionType) {
CollectionType.TVSHOWS ->
CollectionFolderTv(
preferences,
destination,
modifier,
)
CollectionType.MOVIES ->
CollectionFolderMovie(
preferences,
destination,
modifier,
)
CollectionType.BOXSETS ->
CollectionFolderGeneric(
preferences,
destination,
true,
modifier,
)
else ->
CollectionFolderGeneric(
preferences,
destination,
false,
modifier,
)
}
}
BaseItemKind.PLAYLIST ->
PlaylistDetails(
destination = destination,
modifier = modifier,
)
BaseItemKind.USER_VIEW ->
CollectionFolderGeneric(
preferences,
destination,
true,
modifier,
)
else -> {
Text("Unsupported item type: ${destination.type}")
}
}
Destination.UpdateApp -> InstallUpdatePage(preferences, modifier)
Destination.License -> LicenseInfo(modifier)
Destination.Search ->
SearchPage(
userPreferences = preferences,
modifier = modifier,
)
}
}

View file

@ -0,0 +1,355 @@
package com.github.damontecres.wholphin.ui.nav
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.DrawerValue
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.NavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.NavigationDrawerScope
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import androidx.tv.material3.rememberDrawerState
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.JellyfinServer
import com.github.damontecres.wholphin.data.JellyfinUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.spacedByWithFooter
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class NavDrawerViewModel
@Inject
constructor(
val serverRepository: ServerRepository,
val api: ApiClient,
val navigationManager: NavigationManager,
) : ViewModel() {
val libraries = MutableLiveData<List<BaseItem>>(listOf())
val selectedIndex = MutableLiveData<Int>(-1)
init {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler(true)) {
val userViews =
api.userViewsApi
.getUserViews()
.content.items
val libraries =
userViews
.filter { it.collectionType in supportedCollectionTypes }
.map { BaseItem.from(it, api) }
Timber.d("Got ${userViews.size} user views filtered to ${libraries.size}")
withContext(Dispatchers.Main) {
this@NavDrawerViewModel.libraries.value = libraries
}
}
}
fun setIndex(index: Int) {
selectedIndex.value = index
}
}
/**
* Display the left side navigation drawer with [DestinationContent] on the right
*/
@Composable
fun NavDrawer(
destination: Destination,
preferences: UserPreferences,
user: JellyfinUser?,
server: JellyfinServer?,
deviceProfile: DeviceProfile,
modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel =
hiltViewModel(
LocalView.current.findViewTreeViewModelStoreOwner()!!,
key = "${server?.id}_${user?.id}", // Keyed to the server & user to ensure its reset when switching either
),
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
val drawerFocusRequester = remember { FocusRequester() }
// If the user presses back while on the home page, open the nav drawer, another back press will quit the app
BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) {
drawerState.setValue(DrawerValue.Open)
drawerFocusRequester.requestFocus()
}
val libraries by viewModel.libraries.observeAsState(listOf())
// A negative index is a built in page, >=0 is a library
val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
val focusRequester = remember { FocusRequester() }
NavigationDrawer(
modifier =
modifier
.focusRequester(drawerFocusRequester),
drawerState = drawerState,
drawerContent = {
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
LazyColumn(
state = listState,
contentPadding = PaddingValues(0.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedByWithFooter(8.dp),
modifier =
Modifier
.focusGroup()
.focusProperties {
onEnter = {
focusRequester.tryRequestFocus()
}
}.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
) {
item {
IconNavItem(
text = user?.name ?: "",
subtext = server?.name ?: server?.url,
icon = Icons.Default.AccountCircle,
selected = false,
onClick = {
viewModel.navigationManager.navigateTo(Destination.UserList)
},
)
}
item {
IconNavItem(
text = "Search",
icon = Icons.Default.Search,
selected = selectedIndex == -2,
onClick = {
viewModel.setIndex(-2)
viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
},
modifier =
Modifier.ifElse(
selectedIndex == -2,
Modifier.focusRequester(focusRequester),
),
)
}
item {
IconNavItem(
text = "Home",
icon = Icons.Default.Home,
selected = selectedIndex == -1,
onClick = {
viewModel.setIndex(-1)
if (destination is Destination.Home) {
viewModel.navigationManager.reloadHome()
} else {
viewModel.navigationManager.goToHome()
}
},
modifier =
Modifier.ifElse(
selectedIndex == -1,
Modifier.focusRequester(focusRequester),
),
)
}
itemsIndexed(libraries) { index, it ->
LibraryNavItem(
library = it,
selected = selectedIndex == index,
onClick = {
viewModel.setIndex(index)
viewModel.navigationManager.navigateToFromDrawer(it.destination())
},
modifier =
Modifier.ifElse(
selectedIndex == index,
Modifier.focusRequester(focusRequester),
),
)
}
item {
IconNavItem(
text = "Settings",
icon = Icons.Default.Settings,
selected = false,
onClick = {
viewModel.navigationManager.navigateTo(
Destination.Settings(
PreferenceScreenOption.BASIC,
),
)
},
modifier = Modifier,
)
}
}
}
},
) {
// Drawer content
DestinationContent(
destination = destination,
preferences = preferences,
deviceProfile = deviceProfile,
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
fun NavigationDrawerScope.IconNavItem(
text: String,
icon: ImageVector,
onClick: () -> Unit,
selected: Boolean,
modifier: Modifier = Modifier,
subtext: String? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val isFocused = interactionSource.collectIsFocusedAsState().value
NavigationDrawerItem(
modifier = modifier,
selected = false,
onClick = onClick,
leadingContent = {
val color =
when {
isFocused -> LocalContentColor.current
selected -> MaterialTheme.colorScheme.border
else -> LocalContentColor.current
}
Icon(
icon,
contentDescription = null,
tint = color,
)
},
supportingContent =
subtext?.let {
{
Text(
text = it,
maxLines = 1,
)
}
},
interactionSource = interactionSource,
) {
Text(
modifier = Modifier,
text = text,
maxLines = 1,
)
}
}
@Composable
fun NavigationDrawerScope.LibraryNavItem(
library: BaseItem,
onClick: () -> Unit,
selected: Boolean,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val icon =
when (library.data.collectionType) {
CollectionType.MOVIES -> R.string.fa_film
CollectionType.TVSHOWS -> R.string.fa_tv
CollectionType.HOMEVIDEOS -> R.string.fa_video
CollectionType.LIVETV -> R.string.fa_tv
CollectionType.MUSIC -> R.string.fa_music
CollectionType.BOXSETS -> R.string.fa_open_folder
CollectionType.PLAYLISTS -> R.string.fa_list_ul
else -> R.string.fa_film
}
val isFocused = interactionSource.collectIsFocusedAsState().value
val color =
when {
isFocused -> Color.Unspecified
selected -> MaterialTheme.colorScheme.border
else -> Color.Unspecified
}
NavigationDrawerItem(
modifier = modifier,
selected = false,
onClick = onClick,
leadingContent = {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = stringResource(icon),
textAlign = TextAlign.Center,
fontSize = 16.sp,
fontFamily = FontAwesome,
color = color,
)
}
},
interactionSource = null,
) {
Text(
modifier = Modifier,
text = library.name ?: library.id.toString(),
maxLines = 1,
)
}
}

View file

@ -0,0 +1,58 @@
package com.github.damontecres.wholphin.ui.nav
import androidx.navigation3.runtime.NavKey
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages navigating between pages and manages the app's back stack
*/
@Singleton
class NavigationManager
@Inject
constructor() {
var backStack: MutableList<NavKey> = mutableListOf()
/**
* Go to the specified [Destination]
*/
fun navigateTo(destination: Destination) {
backStack.add(destination)
}
/**
* Go to the specified [Destination], but reset the back stack to Home first
*/
fun navigateToFromDrawer(destination: Destination) {
goToHome()
backStack.add(destination)
}
/**
* Go to the previous page
*/
fun goBack() {
backStack.removeLastOrNull()
}
/**
* Go all the way back to the home page
*/
fun goToHome() {
while (backStack.size > 1) {
backStack.removeLastOrNull()
}
if (backStack[0] !is Destination.Home) {
backStack[0] = Destination.Home()
}
}
/**
* Go all the way back to the home page, and reload it from scratch
*/
fun reloadHome() {
goToHome()
val id = (backStack[0] as Destination.Home).id + 1
backStack[0] = Destination.Home(id)
}
}

View file

@ -0,0 +1,32 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
import androidx.media3.common.Player
import androidx.media3.common.Player.Listener
import com.github.damontecres.wholphin.ui.findActivity
import com.github.damontecres.wholphin.ui.keepScreenOn
/**
* Starts a [Player.Listener] that ensures the screen stays on without a screen saber during playback
*
* This will clean up the listener when disposed
*/
@Composable
fun AmbientPlayerListener(player: Player) {
val context = LocalContext.current
DisposableEffect(player) {
val listener =
object : Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
context.findActivity()?.keepScreenOn(isPlaying)
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
context.findActivity()?.keepScreenOn(false)
}
}
}

View file

@ -0,0 +1,51 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.annotation.IntRange
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.debounce
/**
* The visibility state of the playback controls. Can [pulseControls] to show the controls for a specified time.
*
* A coroutine must call [observe]
*/
class ControllerViewState internal constructor(
@param:IntRange(from = 0)
private val hideMilliseconds: Long,
val controlsEnabled: Boolean,
) {
private val channel = Channel<Long>(CONFLATED)
private var _controlsVisible by mutableStateOf(false)
val controlsVisible get() = _controlsVisible
fun showControls(milliseconds: Long = hideMilliseconds) {
if (controlsEnabled) {
_controlsVisible = true
}
pulseControls(milliseconds)
}
fun hideControls() {
_controlsVisible = false
}
fun pulseControls(milliseconds: Long = hideMilliseconds) {
channel.trySend(milliseconds)
}
@OptIn(FlowPreview::class)
suspend fun observe() {
channel
.consumeAsFlow()
.debounce { it }
.collect {
_controlsVisible = false
}
}
}

View file

@ -0,0 +1,57 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
// This file is various functions for testing KeyEvent types
fun isDirectionalDpad(event: KeyEvent): Boolean =
event.key == Key.DirectionUp ||
event.key == Key.DirectionDown ||
event.key == Key.DirectionLeft ||
event.key == Key.DirectionRight ||
event.key == Key.DirectionUpRight ||
event.key == Key.DirectionUpLeft ||
event.key == Key.DirectionDownRight ||
event.key == Key.DirectionDownLeft
fun isDpad(event: KeyEvent): Boolean = event.key == Key.DirectionCenter || isDirectionalDpad(event)
fun isEnterKey(event: KeyEvent) = event.key == Key.DirectionCenter || event.key == Key.Enter || event.key == Key.NumPadEnter
fun isMedia(event: KeyEvent): Boolean =
event.key == Key.MediaPlay ||
event.key == Key.MediaPause ||
event.key == Key.MediaPlayPause ||
event.key == Key.MediaFastForward ||
event.key == Key.MediaSkipForward ||
event.key == Key.MediaRewind ||
event.key == Key.MediaSkipBackward ||
event.key == Key.MediaNext ||
event.key == Key.MediaPrevious
fun isBackwardButton(event: KeyEvent): Boolean =
event.key == Key.PageUp ||
event.key == Key.ChannelUp ||
event.key == Key.MediaPrevious ||
event.key == Key.MediaRewind ||
event.key == Key.MediaSkipBackward
fun isForwardButton(event: KeyEvent): Boolean =
event.key == Key.PageDown ||
event.key == Key.ChannelDown ||
event.key == Key.MediaNext ||
event.key == Key.MediaFastForward ||
event.key == Key.MediaSkipForward
/**
* Whether the [KeyEvent] is a key up event pressing media play or media play/pause
*/
fun isPlayKeyUp(key: KeyEvent) = key.type == KeyEventType.KeyUp && (key.key == Key.MediaPlay || key.key == Key.MediaPlayPause)
fun isUp(event: KeyEvent): Boolean = event.key == Key.DirectionUp || event.key == Key.SystemNavigationUp
fun isDown(event: KeyEvent): Boolean = event.key == Key.DirectionDown || event.key == Key.SystemNavigationDown

View file

@ -0,0 +1,39 @@
package com.github.damontecres.wholphin.ui.playback
data class SubtitleStream(
val index: Int,
val language: String?,
val title: String?,
val codec: String?,
val codecTag: String?,
val external: Boolean,
val forced: Boolean,
val default: Boolean,
) {
val displayName: String
get() =
listOfNotNull(
language,
title,
codec,
).joinToString(" - ").ifBlank { "Unknown" }
}
data class AudioStream(
val index: Int,
val language: String?,
val title: String?,
val codec: String?,
val codecTag: String?,
val channels: Int?,
val channelLayout: String?,
) {
val displayName: String
get() =
listOfNotNull(
language,
title,
codec,
channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" },
).joinToString(" - ").ifBlank { "Unknown" }
}

View file

@ -0,0 +1,197 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Layout for showing the next up episode during playback
*/
@Composable
fun NextUpEpisode(
title: String?,
description: String?,
imageUrl: String?,
onClick: () -> Unit,
timeLeft: Duration?,
modifier: Modifier = Modifier,
aspectRatio: Float = 16f / 9,
) {
val interactionSource = remember { MutableInteractionSource() }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Box(
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Up Next...",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp),
) {
NextUpCard(
imageUrl = imageUrl,
onClick = onClick,
timeLeft = timeLeft,
interactionSource = interactionSource,
modifier =
Modifier
// .fillMaxWidth(.4f)
// .fillMaxHeight()
.focusRequester(focusRequester),
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxHeight()
.weight(1f),
) {
Text(
text = title ?: "",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier,
)
Text(
text = description ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Ellipsis,
modifier = Modifier,
)
}
}
}
}
}
@Composable
fun NextUpCard(
imageUrl: String?,
onClick: () -> Unit,
timeLeft: Duration?,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
onClick = onClick,
interactionSource = interactionSource,
) {
Box(modifier = Modifier) {
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier,
)
if (timeLeft != null && timeLeft > Duration.ZERO) {
Box(
modifier =
Modifier
.align(Alignment.Center)
.background(
AppColors.TransparentBlack50,
shape = CircleShape,
),
) {
Text(
text = timeLeft.toString(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(8.dp),
)
}
} else {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.size(60.dp)
.align(Alignment.Center),
)
}
}
}
}
@PreviewTvSpec
@Composable
private fun NextUpEpisodePreview() {
WholphinTheme(true) {
NextUpEpisode(
title = "Episode Title",
description = "This is the description of the episode. It might be long.",
imageUrl = "",
onClick = {},
aspectRatio = 4f / 3,
timeLeft = 30.seconds,
modifier =
Modifier
.padding(16.dp)
.height(200.dp)
.width(400.dp)
// .fillMaxWidth(.4f)
// .align(Alignment.BottomCenter)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
shape = RoundedCornerShape(8.dp),
),
)
}
}

View file

@ -0,0 +1,14 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.ui.layout.ContentScale
val playbackScaleOptions =
mapOf(
ContentScale.Fit to "Fit",
ContentScale.None to "None",
ContentScale.Crop to "Crop",
// ContentScale.Inside to "Inside",
ContentScale.FillBounds to "Fill",
ContentScale.FillWidth to "Fill Width",
ContentScale.FillHeight to "Fill Height",
)

View file

@ -0,0 +1,658 @@
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
package com.github.damontecres.wholphin.ui.playback
import android.view.Gravity
import androidx.annotation.DrawableRes
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
sealed interface PlaybackAction {
data object ShowDebug : PlaybackAction
data object ShowPlaylist : PlaybackAction
data object ShowVideoFilterDialog : PlaybackAction
data class ToggleCaptions(
val index: Int,
) : PlaybackAction
data class ToggleAudio(
val index: Int,
) : PlaybackAction
data class PlaybackSpeed(
val value: Float,
) : PlaybackAction
data class Scale(
val scale: ContentScale,
) : PlaybackAction
data object Previous : PlaybackAction
data object Next : PlaybackAction
}
@OptIn(UnstableApi::class)
@Composable
fun PlaybackControls(
subtitleStreams: List<SubtitleStream>,
playerControls: Player,
controllerViewState: ControllerViewState,
onPlaybackActionClick: (PlaybackAction) -> Unit,
showDebugInfo: Boolean,
onSeekProgress: (Long) -> Unit,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
moreButtonOptions: MoreButtonOptions,
subtitleIndex: Int?,
audioIndex: Int?,
audioStreams: List<AudioStream>,
playbackSpeed: Float,
scale: ContentScale,
seekBarIntervals: Int,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
initialFocusRequester: FocusRequester = remember { FocusRequester() },
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val scope = rememberCoroutineScope()
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val onControllerInteraction = {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
controllerViewState.pulseControls()
}
val onControllerInteractionForDialog = {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
controllerViewState.pulseControls(Long.MAX_VALUE)
}
LaunchedEffect(controllerViewState.controlsVisible) {
if (controllerViewState.controlsVisible) {
initialFocusRequester.tryRequestFocus()
}
}
Column(
modifier = modifier.bringIntoViewRequester(bringIntoViewRequester),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
SeekBar(
player = playerControls,
controllerViewState = controllerViewState,
onSeekProgress = onSeekProgress,
interactionSource = seekBarInteractionSource,
isEnabled = seekEnabled,
intervals = seekBarIntervals,
seekBack = seekBack,
seekForward = seekForward,
modifier =
Modifier
.padding(vertical = 0.dp)
.fillMaxWidth(.95f),
)
Box(
modifier =
Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth(),
) {
LeftPlaybackButtons(
onControllerInteraction = onControllerInteraction,
onPlaybackActionClick = onPlaybackActionClick,
showDebugInfo = showDebugInfo,
moreButtonOptions = moreButtonOptions,
modifier = Modifier.align(Alignment.CenterStart),
)
PlaybackButtons(
player = playerControls,
initialFocusRequester = initialFocusRequester,
onControllerInteraction = onControllerInteraction,
onPlaybackActionClick = onPlaybackActionClick,
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekBack = seekBack,
seekForward = seekForward,
skipBackOnResume = skipBackOnResume,
modifier = Modifier.align(Alignment.Center),
)
Row(
modifier = Modifier.align(Alignment.CenterEnd),
) {
currentSegment?.let { segment ->
Button(
onClick = {
playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
},
modifier =
Modifier
.align(Alignment.CenterVertically)
.padding(end = 32.dp),
) {
Text(
text = "Skip ${segment.type.serialName}",
)
}
}
RightPlaybackButtons(
subtitleStreams = subtitleStreams,
onControllerInteraction = onControllerInteraction,
onControllerInteractionForDialog = onControllerInteractionForDialog,
onPlaybackActionClick = onPlaybackActionClick,
subtitleIndex = subtitleIndex,
audioStreams = audioStreams,
audioIndex = audioIndex,
playbackSpeed = playbackSpeed,
scale = scale,
modifier = Modifier,
)
}
}
}
}
@OptIn(UnstableApi::class, ExperimentalMaterial3Api::class)
@Composable
fun SeekBar(
player: Player,
isEnabled: Boolean,
intervals: Int,
controllerViewState: ControllerViewState,
onSeekProgress: (Long) -> Unit,
seekBack: Duration,
seekForward: Duration,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
var bufferedProgress by remember(player) { mutableFloatStateOf(player.bufferedPosition.toFloat() / player.duration) }
var position by remember(player) { mutableLongStateOf(player.currentPosition) }
var progress by remember(player) { mutableFloatStateOf(player.currentPosition.toFloat() / player.duration) }
LaunchedEffect(player) {
while (isActive) {
bufferedProgress = player.bufferedPosition.toFloat() / player.duration
position = player.currentPosition
progress = player.currentPosition.toFloat() / player.duration
delay(250L)
}
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
IntervalSeekBarImpl(
progress = progress,
bufferedProgress = bufferedProgress,
onSeek = {
onSeekProgress(it)
},
controllerViewState = controllerViewState,
// intervals = intervals,
modifier = Modifier.fillMaxWidth(),
interactionSource = interactionSource,
enabled = isEnabled,
durationMs = player.contentDuration,
seekBack = seekBack,
seekForward = seekForward,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = (position / 1000).seconds.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.padding(8.dp),
)
Text(
text = "-" + ((player.duration - position) / 1000).seconds.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.padding(8.dp),
)
}
}
}
private val buttonSpacing = 4.dp
@Composable
fun LeftPlaybackButtons(
onControllerInteraction: () -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit,
showDebugInfo: Boolean,
moreButtonOptions: MoreButtonOptions,
modifier: Modifier = Modifier,
) {
var showMoreOptions by remember { mutableStateOf(false) }
Row(
modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
) {
// More options
PlaybackButton(
iconRes = R.drawable.baseline_more_vert_96,
onClick = {
onControllerInteraction.invoke()
showMoreOptions = true
},
enabled = true,
onControllerInteraction = onControllerInteraction,
)
}
if (showMoreOptions) {
val options =
buildList {
addAll(moreButtonOptions.options.keys)
add(if (showDebugInfo) "Hide debug info" else "Show debug info")
}
BottomDialog(
choices = options,
onDismissRequest = { showMoreOptions = false },
onSelectChoice = { index, choice ->
val action = moreButtonOptions.options[choice] ?: PlaybackAction.ShowDebug
onPlaybackActionClick.invoke(action)
},
gravity = Gravity.START,
)
}
}
private val speedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "2.0")
@Composable
fun RightPlaybackButtons(
subtitleStreams: List<SubtitleStream>,
onControllerInteraction: () -> Unit,
onControllerInteractionForDialog: () -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit,
subtitleIndex: Int?,
audioStreams: List<AudioStream>,
audioIndex: Int?,
playbackSpeed: Float,
scale: ContentScale,
modifier: Modifier = Modifier,
) {
var showCaptionDialog by remember { mutableStateOf(false) }
var showOptionsDialog by remember { mutableStateOf(false) }
var showAudioDialog by remember { mutableStateOf(false) }
var showSpeedDialog by remember { mutableStateOf(false) }
var showScaleDialog by remember { mutableStateOf(false) }
Row(
modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
) {
// Captions
PlaybackButton(
enabled = subtitleStreams.isNotEmpty(),
iconRes = R.drawable.captions_svgrepo_com,
onClick = {
onControllerInteractionForDialog.invoke()
showCaptionDialog = true
},
onControllerInteraction = onControllerInteraction,
)
// Playback speed, etc
PlaybackButton(
iconRes = R.drawable.vector_settings,
onClick = {
onControllerInteractionForDialog.invoke()
showOptionsDialog = true
},
enabled = true,
onControllerInteraction = onControllerInteraction,
)
}
if (showCaptionDialog) {
val options = subtitleStreams.map { it.displayName }
Timber.v("subtitleIndex=$subtitleIndex, options=$options")
val currentChoice =
subtitleStreams.indexOfFirstOrNull { it.index == subtitleIndex }?.plus(1) ?: 0
BottomDialog(
choices = listOf("None") + options,
currentChoice = currentChoice,
onDismissRequest = {
onControllerInteraction.invoke()
showCaptionDialog = false
},
onSelectChoice = { index, _ ->
val send =
if (index == 0) {
-1
} else {
subtitleStreams[index - 1].index
}
onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(send))
},
gravity = Gravity.END,
)
}
if (showOptionsDialog) {
val options = listOf("Audio Track", "Playback Speed", "Video Scale")
BottomDialog(
choices = options,
currentChoice = null,
onDismissRequest = {
onControllerInteraction.invoke()
showOptionsDialog = false
},
onSelectChoice = { index, _ ->
when (index) {
0 -> showAudioDialog = true
1 -> showSpeedDialog = true
2 -> showScaleDialog = true
}
},
gravity = Gravity.END,
)
}
if (showAudioDialog) {
BottomDialog(
choices = audioStreams.map { it.displayName },
currentChoice = audioStreams.indexOfFirstOrNull { it.index == audioIndex },
onDismissRequest = {
onControllerInteraction.invoke()
showAudioDialog = false
},
onSelectChoice = { index, _ ->
onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(audioStreams[index].index))
},
gravity = Gravity.END,
)
}
if (showSpeedDialog) {
BottomDialog(
choices = speedOptions,
currentChoice = speedOptions.indexOf(playbackSpeed.toString()),
onDismissRequest = {
onControllerInteraction.invoke()
showSpeedDialog = false
},
onSelectChoice = { _, value ->
onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat()))
},
gravity = Gravity.END,
)
}
if (showScaleDialog) {
BottomDialog(
choices = playbackScaleOptions.values.toList(),
currentChoice = playbackScaleOptions.keys.toList().indexOf(scale),
onDismissRequest = {
onControllerInteraction.invoke()
showScaleDialog = false
},
onSelectChoice = { index, _ ->
onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index]))
},
gravity = Gravity.END,
)
}
}
@OptIn(UnstableApi::class)
@Composable
fun PlaybackButtons(
player: Player,
initialFocusRequester: FocusRequester,
onControllerInteraction: () -> Unit,
onPlaybackActionClick: (PlaybackAction) -> Unit,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(buttonSpacing),
) {
PlaybackButton(
iconRes = R.drawable.baseline_skip_previous_24,
onClick = {
onControllerInteraction.invoke()
onPlaybackActionClick.invoke(PlaybackAction.Previous)
},
enabled = previousEnabled,
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = R.drawable.baseline_fast_rewind_24,
onClick = {
onControllerInteraction.invoke()
player.seekBack(seekBack)
},
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
modifier = Modifier.focusRequester(initialFocusRequester),
iconRes = if (showPlay) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24,
onClick = {
onControllerInteraction.invoke()
if (showPlay) {
player.play()
skipBackOnResume?.let {
player.seekBack(it)
}
} else {
player.pause()
}
},
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = R.drawable.baseline_fast_forward_24,
onClick = {
onControllerInteraction.invoke()
player.seekForward(seekForward)
},
onControllerInteraction = onControllerInteraction,
)
PlaybackButton(
iconRes = R.drawable.baseline_skip_next_24,
onClick = {
onControllerInteraction.invoke()
onPlaybackActionClick.invoke(PlaybackAction.Next)
},
enabled = nextEnabled,
onControllerInteraction = onControllerInteraction,
)
}
}
@Composable
fun PlaybackButton(
@DrawableRes iconRes: Int,
onClick: () -> Unit,
onControllerInteraction: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val selectedColor = MaterialTheme.colorScheme.border
Button(
enabled = enabled,
onClick = onClick,
shape = ButtonDefaults.shape(CircleShape),
colors =
ButtonDefaults.colors(
containerColor = AppColors.TransparentBlack25,
focusedContainerColor = selectedColor,
),
contentPadding = PaddingValues(4.dp),
modifier =
modifier
.padding(4.dp)
.size(44.dp, 44.dp)
.onFocusChanged { onControllerInteraction.invoke() },
) {
Icon(
modifier = Modifier.fillMaxSize(),
painter = painterResource(iconRes),
contentDescription = "",
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
@Composable
private fun BottomDialog(
choices: List<String>,
onDismissRequest: () -> Unit,
onSelectChoice: (Int, String) -> Unit,
gravity: Int,
currentChoice: Int? = null,
) {
// TODO enforcing a width ends up ignore the gravity
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = true),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre
window.setDimAmount(0f) // Remove dimmed background of ongoing playback
}
Box(
modifier =
Modifier
.wrapContentSize()
.padding(8.dp)
.background(Color.DarkGray),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
// .widthIn(max = 240.dp)
.wrapContentWidth(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
choices.forEachIndexed { index, choice ->
val interactionSource = remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value
val color =
if (focused) {
MaterialTheme.colorScheme.inverseOnSurface
} else {
MaterialTheme.colorScheme.onSurface
}
ListItem(
selected = index == currentChoice,
onClick = {
onDismissRequest()
onSelectChoice(index, choice)
},
leadingContent = {
if (index == currentChoice) {
Box(
modifier =
Modifier
.padding(horizontal = 4.dp)
.clip(CircleShape)
.align(Alignment.Center)
.background(color)
.size(8.dp),
)
}
},
headlineContent = {
Text(
text = choice,
color = color,
)
},
interactionSource = interactionSource,
)
}
}
}
}
}
data class MoreButtonOptions(
val options: Map<String, PlaybackAction>,
)

View file

@ -0,0 +1,97 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.media3.common.Player
import androidx.media3.common.util.Util
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import kotlin.time.Duration
/**
* Handles [KeyEvent]s during playback on [PlaybackPage]
*/
class PlaybackKeyHandler(
private val player: Player,
private val controlsEnabled: Boolean,
private val skipWithLeftRight: Boolean,
private val seekBack: Duration,
private val seekForward: Duration,
private val controllerViewState: ControllerViewState,
private val updateSkipIndicator: (Long) -> Unit,
private val skipBackOnResume: Duration?,
) {
fun onKeyEvent(it: KeyEvent): Boolean {
var result = true
if (!controlsEnabled) {
result = false
} else if (it.type != KeyEventType.KeyUp) {
result = false
} else if (isDpad(it)) {
if (!controllerViewState.controlsVisible) {
if (skipWithLeftRight && it.key == Key.DirectionLeft) {
updateSkipIndicator(-seekBack.inWholeMilliseconds)
player.seekBack(seekBack)
} else if (skipWithLeftRight && it.key == Key.DirectionRight) {
player.seekForward(seekForward)
updateSkipIndicator(seekForward.inWholeMilliseconds)
} else {
controllerViewState.showControls()
}
} else {
// When controller is visible, its buttons will handle pulsing
}
} else if (isMedia(it)) {
when (it.key) {
Key.MediaPlay -> {
Util.handlePlayButtonAction(player)
skipBackOnResume?.let {
player.seekBack(it)
}
}
Key.MediaPause -> {
Util.handlePauseButtonAction(player)
controllerViewState.showControls()
}
Key.MediaPlayPause -> {
Util.handlePlayPauseButtonAction(player)
if (!player.isPlaying) {
controllerViewState.showControls()
} else {
skipBackOnResume?.let {
player.seekBack(it)
}
}
}
Key.MediaFastForward, Key.MediaSkipForward -> {
player.seekForward(seekForward)
updateSkipIndicator(seekForward.inWholeMilliseconds)
}
Key.MediaRewind, Key.MediaSkipBackward -> {
player.seekBack(seekBack)
updateSkipIndicator(-seekBack.inWholeMilliseconds)
}
Key.MediaNext -> if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT)) player.seekToNext()
Key.MediaPrevious -> if (player.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS)) player.seekToPrevious()
else -> result = false
}
} else if (it.key == Key.Enter && !controllerViewState.controlsVisible) {
controllerViewState.showControls()
} else if (it.key == Key.Back && controllerViewState.controlsVisible) {
// TODO change this to a BackHandler?
controllerViewState.hideControls()
} else {
controllerViewState.pulseControls()
result = false
}
return result
}
}

View file

@ -0,0 +1,503 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.common.Player
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.TrickplayInfo
import kotlin.time.Duration
private val titleTextSize = 28.sp
private val subtitleTextSize = 18.sp
/**
* The overlay during playback showing controls, seek preview image, debug info, etc
*/
@Composable
fun PlaybackOverlay(
title: String?,
subtitleStreams: List<SubtitleStream>,
chapters: List<Chapter>,
playerControls: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onSeekBarChange: (Long) -> Unit,
showDebugInfo: Boolean,
scale: ContentScale,
playbackSpeed: Float,
moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?,
audioStreams: List<AudioStream>,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
subtitle: String? = null,
trickplayInfo: TrickplayInfo? = null,
trickplayUrlFor: (Int) -> String? = { null },
playlist: Playlist = Playlist(listOf(), 0),
onClickPlaylist: (BaseItem) -> Unit = {},
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
// Will be used for preview/trick play images
var seekProgressMs by remember(seekBarFocused) { mutableLongStateOf(playerControls.currentPosition) }
var seekProgressPercent = (seekProgressMs.toDouble() / playerControls.duration).toFloat()
val chapterInteractionSources =
remember(chapters.size) { List(chapters.size) { MutableInteractionSource() } }
val density = LocalDensity.current
val titleHeight =
remember {
if (title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
}
val subtitleHeight =
remember {
if (subtitle.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
}
// This will be calculated after composition
var controllerHeight by remember { mutableStateOf(0.dp) }
var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
Box(
modifier = modifier,
contentAlignment = Alignment.BottomCenter,
) {
AnimatedVisibility(
state == OverlayViewState.CONTROLLER,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
val nextState =
if (chapters.isNotEmpty()) {
OverlayViewState.CHAPTERS
} else if (playlist.hasNext()) {
OverlayViewState.QUEUE
} else {
null
}
Controller(
title = title,
subtitleStreams = subtitleStreams,
playerControls = playerControls,
controllerViewState = controllerViewState,
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBack = seekBack,
skipBackOnResume = skipBackOnResume,
seekForward = seekForward,
onPlaybackActionClick = onPlaybackActionClick,
onSeekProgress = {
onSeekBarChange(it)
seekProgressMs = it
},
showDebugInfo = showDebugInfo,
scale = scale,
playbackSpeed = playbackSpeed,
moreButtonOptions = moreButtonOptions,
currentPlayback = currentPlayback,
audioStreams = audioStreams,
subtitle = subtitle,
seekBarInteractionSource = seekBarInteractionSource,
nextState = nextState,
onNextStateFocus = {
nextState?.let { state = it }
},
currentSegment = currentSegment,
modifier =
Modifier
// Don't use key events because this control has vertical items so up/down is tough to manage
.onGloballyPositioned {
controllerHeight = with(density) { it.size.height.toDp() }
},
)
}
AnimatedVisibility(
state == OverlayViewState.CHAPTERS,
enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (chapters.isNotEmpty()) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(8.dp)
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyUp && isUp(e)) {
state = OverlayViewState.CONTROLLER
true
} else {
false
}
},
) {
Text(
text = "Chapters",
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusRequester)
.onFocusChanged {
if (it.hasFocus) {
controllerViewState.pulseControls()
}
},
) {
itemsIndexed(chapters) { index, chapter ->
val interactionSource = chapterInteractionSources[index]
val isFocused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(isFocused) {
if (isFocused) controllerViewState.pulseControls()
}
ChapterCard(
name = chapter.name,
position = chapter.position,
imageUrl = chapter.imageUrl,
onClick = {
playerControls.seekTo(chapter.position.inWholeMilliseconds)
controllerViewState.hideControls()
},
interactionSource = interactionSource,
modifier =
Modifier.ifElse(
index == 0,
Modifier.focusRequester(focusRequester),
),
)
}
}
if (playlist.hasNext()) {
Text(
text = "Queue",
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(start = 16.dp, top = 16.dp)
.onFocusChanged {
if (it.isFocused) state = OverlayViewState.QUEUE
}.focusable(),
)
}
}
}
}
AnimatedVisibility(
state == OverlayViewState.QUEUE,
enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (playlist.hasNext()) {
val items = remember { playlist.upcomingItems() }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(8.dp)
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyUp && isUp(e)) {
if (chapters.isNotEmpty()) {
state = OverlayViewState.CHAPTERS
} else {
state = OverlayViewState.CONTROLLER
}
true
} else {
false
}
},
) {
Text(
text = "Queue",
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.focusRestorer(focusRequester)
.onFocusChanged {
if (it.hasFocus) {
controllerViewState.pulseControls()
}
},
) {
itemsIndexed(items) { index, item ->
val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(isFocused) {
if (isFocused) controllerViewState.pulseControls()
}
SeasonCard(
item = item,
onClick = {
onClickPlaylist.invoke(item)
controllerViewState.hideControls()
},
onLongClick = {},
imageHeight = 140.dp,
interactionSource = interactionSource,
modifier =
Modifier.ifElse(
index == 0,
Modifier.focusRequester(focusRequester),
),
)
}
}
}
}
}
if (seekBarInteractionSource.collectIsFocusedAsState().value) {
LaunchedEffect(Unit) {
seekProgressPercent =
(playerControls.currentPosition.toFloat() / playerControls.duration)
}
}
if (trickplayInfo != null) {
AnimatedVisibility(
seekProgressPercent >= 0 && seekBarFocused,
) {
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
val index =
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
val imageUrl = trickplayUrlFor(index)
if (imageUrl != null) {
Box(
modifier =
Modifier
.align(Alignment.Center)
.fillMaxWidth(.95f),
) {
SeekPreviewImage(
modifier =
Modifier
.align(Alignment.BottomStart)
.offsetByPercent(
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
).padding(bottom = controllerHeight - titleHeight - subtitleHeight),
previewImageUrl = imageUrl,
duration = playerControls.duration,
seekProgressMs = seekProgressMs,
videoWidth = trickplayInfo.width,
videoHeight = trickplayInfo.height,
trickPlayInfo = trickplayInfo,
)
}
}
}
}
AnimatedVisibility(
showDebugInfo && controllerViewState.controlsVisible,
modifier =
Modifier
.align(Alignment.TopStart),
) {
currentPlayback?.tracks?.letNotEmpty {
PlaybackTrackInfo(
trackSupport = it,
modifier =
Modifier
.align(Alignment.TopStart)
.padding(16.dp)
.background(AppColors.TransparentBlack50),
)
}
}
}
}
/**
* The view state of the overlay
*/
enum class OverlayViewState {
CONTROLLER,
CHAPTERS,
QUEUE,
}
/**
* A wrapper for the playback controls to show title and other information, plus the actual controls
*/
@Composable
fun Controller(
title: String?,
subtitleStreams: List<SubtitleStream>,
playerControls: Player,
controllerViewState: ControllerViewState,
showPlay: Boolean,
previousEnabled: Boolean,
nextEnabled: Boolean,
seekEnabled: Boolean,
seekBack: Duration,
skipBackOnResume: Duration?,
seekForward: Duration,
onPlaybackActionClick: (PlaybackAction) -> Unit,
onSeekProgress: (Long) -> Unit,
showDebugInfo: Boolean,
scale: ContentScale,
playbackSpeed: Float,
moreButtonOptions: MoreButtonOptions,
currentPlayback: CurrentPlayback?,
audioStreams: List<AudioStream>,
nextState: OverlayViewState?,
currentSegment: MediaSegmentDto?,
modifier: Modifier = Modifier,
subtitle: String? = null,
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onNextStateFocus: () -> Unit = {},
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp),
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontSize = titleTextSize,
)
}
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
fontSize = subtitleTextSize,
)
}
}
PlaybackControls(
modifier = Modifier.fillMaxWidth(),
subtitleStreams = subtitleStreams,
playerControls = playerControls,
onPlaybackActionClick = onPlaybackActionClick,
controllerViewState = controllerViewState,
showDebugInfo = showDebugInfo,
onSeekProgress = {
onSeekProgress(it)
},
showPlay = showPlay,
previousEnabled = previousEnabled,
nextEnabled = nextEnabled,
seekEnabled = seekEnabled,
seekBarInteractionSource = seekBarInteractionSource,
moreButtonOptions = moreButtonOptions,
subtitleIndex = currentPlayback?.subtitleIndex,
audioIndex = currentPlayback?.audioIndex,
audioStreams = audioStreams,
playbackSpeed = playbackSpeed,
scale = scale,
seekBarIntervals = 16,
seekBack = seekBack,
seekForward = seekForward,
skipBackOnResume = skipBackOnResume,
currentSegment = currentSegment,
)
when (nextState) {
OverlayViewState.CHAPTERS ->
Text(
text = "Chapters",
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(start = 16.dp, top = 16.dp)
.onFocusChanged {
if (it.isFocused) onNextStateFocus.invoke()
}.focusable(),
)
OverlayViewState.QUEUE ->
Text(
text = "Queue",
style = MaterialTheme.typography.titleLarge,
modifier =
Modifier
.padding(start = 16.dp, top = 16.dp)
.onFocusChanged {
if (it.isFocused) onNextStateFocus.invoke()
}.focusable(),
)
else -> Spacer(Modifier.height(32.dp))
}
}
}

View file

@ -0,0 +1,453 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.Player
import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import androidx.media3.ui.compose.state.rememberPresentationState
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* The actual playback page which shows media & playback controls
*/
@OptIn(UnstableApi::class)
@Composable
fun PlaybackPage(
preferences: UserPreferences,
deviceProfile: DeviceProfile,
destination: Destination.Playback,
modifier: Modifier = Modifier,
viewModel: PlaybackViewModel = hiltViewModel(),
) {
val prefs = preferences.appPreferences.playbackPreferences
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(destination.itemId) {
viewModel.init(destination, deviceProfile, preferences)
}
val player = viewModel.player
val stream by viewModel.stream.observeAsState(null)
val title by viewModel.title.observeAsState(null)
val subtitle by viewModel.subtitle.observeAsState(null)
val duration by viewModel.duration.observeAsState(null)
val audioStreams by viewModel.audioStreams.observeAsState(listOf())
val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf())
val trickplay by viewModel.trickplay.observeAsState(null)
val chapters by viewModel.chapters.observeAsState(listOf())
val currentPlayback by viewModel.currentPlayback.observeAsState(null)
val currentSegment by viewModel.currentSegment.observeAsState(null)
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
var cues by remember { mutableStateOf<List<Cue>>(listOf()) }
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
val nextUp by viewModel.nextUp.observeAsState(null)
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
// TODO move to viewmodel?
val cueListener =
remember {
object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
cues = cueGroup.cues
}
}
}
OneTimeLaunchedEffect {
player.addListener(cueListener)
}
DisposableEffect(Unit) {
onDispose { player.removeListener(cueListener) }
}
AmbientPlayerListener(player)
if (stream == null) {
LoadingPage()
} else {
stream?.let {
var contentScale by remember { mutableStateOf(ContentScale.Fit) }
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
val presentationState = rememberPresentationState(player)
val scaledModifier =
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
val focusRequester = remember { FocusRequester() }
val playPauseState = rememberPlayPauseButtonState(player)
val seekBarState = rememberSeekBarState(player, scope)
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
val controllerViewState =
remember {
ControllerViewState(
preferences.appPreferences.playbackPreferences.controllerTimeoutMs,
true,
)
}.also {
LaunchedEffect(it) {
it.observe()
}
}
var skipIndicatorDuration by remember { mutableLongStateOf(0L) }
LaunchedEffect(controllerViewState.controlsVisible) {
// If controller shows/hides, immediately cancel the skip indicator
skipIndicatorDuration = 0L
}
var skipPosition by remember { mutableLongStateOf(0L) }
val updateSkipIndicator = { delta: Long ->
if (skipIndicatorDuration > 0 && delta < 0 || skipIndicatorDuration < 0 && delta > 0) {
skipIndicatorDuration = 0
}
skipIndicatorDuration += delta
skipPosition = player.currentPosition
}
val keyHandler =
PlaybackKeyHandler(
player = player,
controlsEnabled = nextUp == null,
skipWithLeftRight = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
controllerViewState = controllerViewState,
updateSkipIndicator = updateSkipIndicator,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
)
val showSegment =
!segmentCancelled && currentSegment != null &&
!controllerViewState.controlsVisible && skipIndicatorDuration == 0L
BackHandler(showSegment) {
segmentCancelled = true
}
Box(
modifier
.background(Color.Black),
) {
val playerSize by animateFloatAsState(if (nextUp == null) 1f else .66f)
Box(
modifier =
Modifier
.fillMaxSize(playerSize)
.align(Alignment.TopCenter)
.onKeyEvent(keyHandler::onKeyEvent)
.focusRequester(focusRequester)
.focusable(),
) {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = scaledModifier,
)
if (presentationState.coverSurface) {
Box(
Modifier
.matchParentSize()
.background(Color.Black),
) {
LoadingPage()
}
}
// If D-pad skipping, show the amount skipped in an animation
if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
SkipIndicator(
durationMs = skipIndicatorDuration,
onFinish = {
skipIndicatorDuration = 0L
},
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 70.dp),
)
// Show a small progress bar along the bottom of the screen
val showSkipProgress = true // TODO get from preferences
if (showSkipProgress) {
duration?.let {
val percent = (skipPosition.milliseconds / it).toFloat()
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.background(MaterialTheme.colorScheme.border)
.clip(RectangleShape)
.height(3.dp)
.fillMaxWidth(percent),
) {
// No-op
}
}
}
}
// The playback controls
AnimatedVisibility(
controllerViewState.controlsVisible,
Modifier,
slideInVertically { it },
slideOutVertically { it },
) {
PlaybackOverlay(
modifier =
Modifier
.padding(WindowInsets.systemBars.asPaddingValues())
.fillMaxSize()
.background(Color.Transparent),
title = title,
subtitle = subtitle,
subtitleStreams = subtitleStreams,
playerControls = player,
controllerViewState = controllerViewState,
showPlay = playPauseState.showPlay,
previousEnabled = true,
nextEnabled = playlist.hasNext(),
seekEnabled = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
onPlaybackActionClick = {
when (it) {
is PlaybackAction.PlaybackSpeed -> {
playbackSpeed = it.value
}
is PlaybackAction.Scale -> {
contentScale = it.scale
}
PlaybackAction.ShowDebug -> {
showDebugInfo = !showDebugInfo
}
PlaybackAction.ShowPlaylist -> TODO()
PlaybackAction.ShowVideoFilterDialog -> TODO()
is PlaybackAction.ToggleAudio -> {
viewModel.changeAudioStream(it.index)
}
is PlaybackAction.ToggleCaptions -> {
viewModel.changeSubtitleStream(it.index)
}
PlaybackAction.Next -> {
// TODO focus is lost
viewModel.playUpNextUp()
}
PlaybackAction.Previous -> {
val pos = player.currentPosition
if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) {
viewModel.playPrevious()
} else {
player.seekToPrevious()
}
}
}
},
onSeekBarChange = seekBarState::onValueChange,
showDebugInfo = showDebugInfo,
scale = contentScale,
playbackSpeed = playbackSpeed,
moreButtonOptions = MoreButtonOptions(mapOf()),
currentPlayback = currentPlayback,
audioStreams = audioStreams,
trickplayInfo = trickplay,
trickplayUrlFor = viewModel::getTrickplayUrl,
chapters = chapters,
playlist = playlist,
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
currentSegment = currentSegment,
)
}
// Subtitles
if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentPlayback?.subtitleIndex != null) {
AndroidView(
factory = { context ->
SubtitleView(context).apply {
setUserDefaultStyle()
setUserDefaultTextSize()
}
},
update = {
it.setCues(cues)
},
onReset = {
it.setCues(null)
},
modifier =
Modifier
.fillMaxSize()
.background(Color.Transparent),
)
}
// Ask to skip intros, etc button
AnimatedVisibility(
showSegment,
modifier =
Modifier
.padding(40.dp)
.align(Alignment.BottomEnd),
) {
currentSegment?.let { segment ->
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
delay(10.seconds)
segmentCancelled = false
}
Button(
onClick = {
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
},
modifier = Modifier.focusRequester(focusRequester),
) {
Text(
text = "Skip ${segment.type.serialName}",
)
}
}
}
}
// Next up episode
BackHandler(nextUp != null) {
viewModel.navigationManager.goBack()
}
AnimatedVisibility(
nextUp != null,
modifier =
Modifier
.align(Alignment.BottomCenter),
) {
nextUp?.let {
var autoPlayEnabled by
remember {
mutableStateOf(
preferences.appPreferences.playbackPreferences.autoPlayNext,
)
}
var timeLeft by remember {
mutableLongStateOf(
preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds,
)
}
BackHandler(timeLeft > 0 && autoPlayEnabled) {
timeLeft = -1
autoPlayEnabled = false
}
if (autoPlayEnabled) {
LaunchedEffect(Unit) {
if (timeLeft == 0L) {
viewModel.playUpNextUp()
} else {
while (timeLeft > 0) {
delay(1.seconds)
timeLeft--
}
if (timeLeft == 0L && autoPlayEnabled) {
viewModel.playUpNextUp()
}
}
}
}
NextUpEpisode(
title =
listOfNotNull(
it.data.seasonEpisode,
it.name,
).joinToString(" - "),
description = it.data.overview,
imageUrl = it.imageUrl,
aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
onClick = { viewModel.playUpNextUp() },
timeLeft = if (autoPlayEnabled) timeLeft.seconds else null,
modifier =
Modifier
.padding(8.dp)
// .height(128.dp)
.fillMaxHeight(.5f)
.fillMaxWidth(.66f)
.align(Alignment.BottomCenter)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
shape = RoundedCornerShape(8.dp),
),
)
}
}
}
}
}
}

View file

@ -0,0 +1,116 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.util.TrackSupport
/**
* Debug info about the current playback tracks
*/
@Composable
fun PlaybackTrackInfo(
trackSupport: List<TrackSupport>,
modifier: Modifier = Modifier,
) {
val selectedWeight = .5f
val weights = listOf(.25f, .4f, .5f, 1f, 1f)
val textStyle =
MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onBackground)
LazyColumn(
modifier = modifier,
) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) {
val texts =
listOf(
"ID",
"Type",
"Codec",
"Supported",
"Labels",
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(selectedWeight),
) {
Text(
text = "Selected",
style = textStyle,
)
}
texts.forEachIndexed { index, text ->
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(weights[index]),
) {
Text(
text = text,
style = textStyle,
)
}
}
}
}
items(trackSupport) { track ->
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) {
val texts =
listOf(
track.id ?: "",
track.type.name,
track.codecs ?: "",
track.supported.name,
track.labels.joinToString(", "),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.weight(selectedWeight),
) {
if (track.selected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.size(12.dp),
)
} else {
Text(
text = "-",
style = textStyle,
)
}
}
texts.forEachIndexed { index, text ->
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(weights[index]),
) {
Text(
text = text,
style = textStyle,
)
}
}
}
}
}
}

View file

@ -0,0 +1,730 @@
package com.github.damontecres.wholphin.ui.playback
import android.content.Context
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistCreator
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.TrackSupport
import com.github.damontecres.wholphin.util.checkForSupport
import com.github.damontecres.wholphin.util.formatDateTime
import com.github.damontecres.wholphin.util.seasonEpisodePadded
import com.github.damontecres.wholphin.util.subtitleMimeTypes
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
import org.jellyfin.sdk.api.client.extensions.trickplayApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.extensions.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
enum class TranscodeType {
DIRECT_PLAY,
DIRECT_STREAM,
TRANSCODE,
}
data class StreamDecision(
val itemId: UUID,
val type: PlayMethod,
val url: String,
)
@HiltViewModel
class PlaybackViewModel
@Inject
constructor(
@param:ApplicationContext val context: Context,
val api: ApiClient,
val playlistCreator: PlaylistCreator,
val navigationManager: NavigationManager,
) : ViewModel() {
val player: ExoPlayer =
ExoPlayer
.Builder(context)
.build()
.apply {
playWhenReady = true
}
val stream = MutableLiveData<StreamDecision?>(null)
val title = MutableLiveData<String?>(null)
val subtitle = MutableLiveData<String?>(null)
val duration = MutableLiveData<Duration?>(null)
val audioStreams = MutableLiveData<List<AudioStream>>(listOf())
val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf())
val currentPlayback = MutableLiveData<CurrentPlayback?>(null)
val trickplay = MutableLiveData<TrickplayInfo?>(null)
val chapters = MutableLiveData<List<Chapter>>(listOf())
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
private lateinit var preferences: UserPreferences
private lateinit var deviceProfile: DeviceProfile
private lateinit var itemId: UUID
private lateinit var dto: BaseItemDto
private var activityListener: TrackActivityPlaybackListener? = null
val nextUp = MutableLiveData<BaseItem?>()
private var isPlaylist = false
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
init {
addCloseable { this@PlaybackViewModel.activityListener?.release() }
addCloseable { player.release() }
}
fun init(
destination: Destination.Playback,
deviceProfile: DeviceProfile,
preferences: UserPreferences,
) {
nextUp.value = null
this.preferences = preferences
this.deviceProfile = deviceProfile
val itemId = destination.itemId
this.itemId = itemId
val item = destination.item
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content
val base =
if (queriedItem.type == BaseItemKind.PLAYLIST) {
isPlaylist = true
val playlist =
playlistCreator.createFromPlaylistId(
queriedItem.id,
destination.startIndex,
destination.shuffle,
)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist
}
// TODO start index
playlist.items.first().data
} else {
queriedItem
}
val played = play(base, destination.positionMs)
if (!played) {
playUpNextUp()
}
if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) {
val playlist =
playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id)
withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist
}
}
maybeSetupPlaylistListener()
}
}
private suspend fun play(
base: BaseItemDto,
positionMs: Long,
): Boolean =
withContext(Dispatchers.IO) {
Timber.i("Playing ${base.id}")
if (base.type !in supportItemKinds) {
showToast(
context,
"Unsupported type '${base.type}', skipping...",
Toast.LENGTH_SHORT,
)
return@withContext false
}
dto = base
val title =
if (base.type == BaseItemKind.EPISODE) {
base.seriesName
} else {
base.name
}
val subtitle =
if (base.type == BaseItemKind.EPISODE) {
buildList {
add(base.seasonEpisodePadded)
add(base.name)
add(base.premiereDate?.let { formatDateTime(it) })
}.filterNotNull().joinToString(" - ")
} else {
base.productionYear?.toString()
}
withContext(Dispatchers.Main) {
this@PlaybackViewModel.title.value = title
this@PlaybackViewModel.subtitle.value = subtitle
}
base.mediaStreams
?.filter { it.type == MediaStreamType.VIDEO }
?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") }
val subtitleStreams =
base.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE }
?.map {
SubtitleStream(
it.index,
it.language,
it.title,
it.codec,
it.codecTag,
it.isExternal,
it.isForced,
it.isDefault,
)
}.orEmpty()
val audioStreams =
base.mediaStreams
?.filter { it.type == MediaStreamType.AUDIO }
?.map {
AudioStream(
it.index,
it.language,
it.title,
it.codec,
it.codecTag,
it.channels,
it.channelLayout,
)
}?.sortedWith(compareBy<AudioStream> { it.language }.thenByDescending { it.channels })
.orEmpty()
// TODO audio selection based on channel layout or preferences or default
val audioLanguage = preferences.userConfig.audioLanguagePreference
val audioIndex =
if (audioLanguage != null) {
audioStreams.firstOrNull { it.language == audioLanguage }?.index
?: audioStreams.firstOrNull()?.index
} else {
audioStreams.firstOrNull()?.index
}
val subtitleMode = preferences.userConfig.subtitleMode
val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference
val subtitleIndex =
when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage != null) {
subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index
} else {
subtitleStreams.firstOrNull()?.index
}
}
SubtitlePlaybackMode.ONLY_FORCED ->
if (subtitleLanguage != null) {
subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index
} else {
subtitleStreams.firstOrNull { it.forced }?.index
}
SubtitlePlaybackMode.SMART -> {
if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) {
subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index
} else {
null
}
}
SubtitlePlaybackMode.DEFAULT -> {
// TODO check for language?
(
subtitleStreams.firstOrNull { it.default && it.forced }
?: subtitleStreams.firstOrNull { it.default }
?: subtitleStreams.firstOrNull { it.forced }
)?.index
}
SubtitlePlaybackMode.NONE -> null
}
// Timber.v("base.mediaStreams=${base.mediaStreams}")
// Timber.v("subtitleTracks=$subtitleStreams")
// Timber.v("audioStreams=$audioStreams")
Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex")
withContext(Dispatchers.Main) {
this@PlaybackViewModel.audioStreams.value = audioStreams
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
changeStreams(
base,
audioIndex,
subtitleIndex,
if (positionMs > 0) positionMs else C.TIME_UNSET,
)
player.prepare()
this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api)
Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}")
}
listenForSegments()
return@withContext true
}
@OptIn(UnstableApi::class)
private suspend fun changeStreams(
item: BaseItemDto,
audioIndex: Int?,
subtitleIndex: Int?,
positionMs: Long = C.TIME_UNSET,
) {
val itemId = item.id
if (currentPlayback.value?.let {
it.itemId == itemId &&
it.audioIndex == audioIndex &&
it.subtitleIndex == subtitleIndex
} == true
) {
Timber.i("No change in playback for changeStreams")
return
}
// TODO if the new audio or subtitle index is already in the streams (eg direct play), should toggle in the player instead
val maxBitrate =
preferences.appPreferences.playbackPreferences.maxBitrate
.takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE
val response by
api.mediaInfoApi
.getPostedPlaybackInfo(
itemId,
PlaybackInfoDto(
startTimeTicks = null,
deviceProfile = deviceProfile,
enableDirectPlay = true,
enableDirectStream = true,
maxAudioChannels = null,
audioStreamIndex = audioIndex,
subtitleStreamIndex = subtitleIndex,
allowVideoStreamCopy = true,
allowAudioStreamCopy = true,
autoOpenLiveStream = true,
mediaSourceId = null,
alwaysBurnInSubtitleWhenTranscoding = null,
maxStreamingBitrate = maxBitrate.toInt(),
),
)
val source = response.mediaSources.firstOrNull()
source?.let { source ->
val mediaUrl =
if (source.supportsDirectPlay) {
api.videosApi.getVideoStreamUrl(
itemId = itemId,
mediaSourceId = source.id,
static = true,
tag = source.eTag,
playSessionId = response.playSessionId,
)
} else if (source.supportsDirectStream) {
api.createUrl(source.transcodingUrl!!)
} else {
api.createUrl(source.transcodingUrl!!)
}
val transcodeType =
when {
source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
source.supportsDirectStream -> PlayMethod.DIRECT_STREAM
source.supportsTranscoding -> PlayMethod.TRANSCODE
else -> throw Exception("No supported playback method")
}
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
Timber.v("Playback decision: $decision")
val externalSubtitle =
source.mediaStreams
?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.index == subtitleIndex && it.isExternal }
?.let {
it.deliveryUrl?.let { deliveryUrl ->
var flags = 0
if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED)
if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT)
MediaItem.SubtitleConfiguration
.Builder(
api.createUrl(deliveryUrl).toUri(),
).setId("${it.index + 1}") // Indexes are 1 based
.setMimeType(subtitleMimeTypes[it.codec])
.setLanguage(it.language)
.setSelectionFlags(flags)
.build()
}
}
val mediaItem =
MediaItem
.Builder()
.setMediaId(itemId.toString())
.setUri(mediaUrl.toUri())
.setSubtitleConfigurations(listOfNotNull(externalSubtitle))
.build()
val playback =
CurrentPlayback(
itemId,
audioIndex,
subtitleIndex,
source.id?.toUUIDOrNull(),
listOf(),
playMethod = transcodeType,
playSessionId = response.playSessionId,
)
withContext(Dispatchers.Main) {
// TODO, don't need to release & recreate when switching streams
this@PlaybackViewModel.activityListener?.let {
it.release()
player.removeListener(it)
}
val activityListener =
TrackActivityPlaybackListener(
api = api,
player = player,
playback = playback,
)
player.addListener(activityListener)
this@PlaybackViewModel.activityListener = activityListener
duration.value = source.runTimeTicks?.ticks
stream.value = decision
currentPlayback.value = playback
player.setMediaItem(
mediaItem,
positionMs,
)
if (audioIndex != null || subtitleIndex != null) {
val onTracksChangedListener =
object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
Timber.v("onTracksChanged: $tracks")
if (tracks.groups.isNotEmpty()) {
applyTrackSelections(
player,
source,
audioIndex,
subtitleIndex,
)
currentPlayback.value =
currentPlayback.value?.copy(
tracks = checkForSupport(tracks),
)
player.removeListener(this)
}
}
}
player.addListener(onTracksChangedListener)
}
}
val trickPlayInfo =
item.trickplay
?.get(source.id)
?.values
?.firstOrNull()
// Timber.v("Trickplay info: $trickPlayInfo")
withContext(Dispatchers.Main) {
trickplay.value = trickPlayInfo
}
}
}
fun changeAudioStream(index: Int) {
val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) {
changeStreams(
dto,
index,
currentPlayback.value?.subtitleIndex,
player.currentPosition,
)
}
}
fun changeSubtitleStream(index: Int?) {
val itemId = currentPlayback.value?.itemId ?: return
viewModelScope.launch(ExceptionHandler()) {
changeStreams(
dto,
currentPlayback.value?.audioIndex,
index,
player.currentPosition,
)
}
}
fun getTrickplayUrl(index: Int): String? {
val itemId = dto.id
val mediaSourceId = currentPlayback.value?.mediaSourceId
val trickPlayInfo = trickplay.value ?: return null
return api.trickplayApi.getTrickplayTileImageUrl(
itemId,
trickPlayInfo.width,
index,
mediaSourceId,
)
}
private fun maybeSetupPlaylistListener() {
playlist.value?.let { playlist ->
if (playlist.hasNext()) {
Timber.v("Adding lister for playlist with ${playlist.items.size} items")
val listener =
object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val nextItem = playlist.peek()
Timber.v("Setting next up to ${nextItem?.id}")
withContext(Dispatchers.Main) {
nextUp.value = nextItem
}
}
}
}
}
player.addListener(listener)
addCloseable { player.removeListener(listener) }
}
}
}
private var segmentJob: Job? = null
private fun listenForSegments() {
segmentJob?.cancel()
segmentJob =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val prefs = preferences.appPreferences.playbackPreferences
val segments by api.mediaSegmentsApi.getItemSegments(itemId)
if (segments.items.isNotEmpty()) {
while (isActive) {
delay(500L)
val currentTicks = withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks }
val currentSegment =
segments.items
.firstOrNull {
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
}
if (currentSegment != null) {
Timber.d(
"Found media segment for %s: %s, %s",
currentSegment.itemId,
currentSegment.id,
currentSegment.type,
)
val behavior =
when (currentSegment.type) {
MediaSegmentType.COMMERCIAL -> prefs.skipCommercials
MediaSegmentType.PREVIEW -> prefs.skipPreviews
MediaSegmentType.RECAP -> prefs.skipRecaps
MediaSegmentType.OUTRO -> prefs.skipIntros
MediaSegmentType.INTRO -> prefs.skipOutros
MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE
}
withContext(Dispatchers.Main) {
when (behavior) {
SkipSegmentBehavior.AUTO_SKIP -> {
this@PlaybackViewModel.currentSegment.value = null
player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1)
}
SkipSegmentBehavior.ASK_TO_SKIP -> {
this@PlaybackViewModel.currentSegment.value = currentSegment
}
else -> {
this@PlaybackViewModel.currentSegment.value = null
}
}
}
} else {
withContext(Dispatchers.Main) {
this@PlaybackViewModel.currentSegment.value = null
}
}
}
}
}
}
fun playUpNextUp() {
playlist.value?.let {
if (it.hasNext()) {
viewModelScope.launch(ExceptionHandler()) {
cancelUpNextEpisode()
val item = it.getAndAdvance()
val played = play(item.data, 0)
if (!played) {
playUpNextUp()
}
}
}
}
}
fun playPrevious() {
playlist.value?.let {
if (it.hasPrevious()) {
viewModelScope.launch(ExceptionHandler()) {
cancelUpNextEpisode()
val item = it.getPreviousAndReverse()
val played = play(item.data, 0)
if (!played) {
playPrevious()
}
}
}
}
}
fun cancelUpNextEpisode() {
nextUp.value = null
}
fun playItemInPlaylist(item: BaseItem) {
playlist.value?.let { playlist ->
viewModelScope.launch(ExceptionHandler()) {
val toPlay = playlist.advanceTo(item.id)
if (toPlay != null) {
val played = play(toPlay.data, 0)
if (!played) {
playUpNextUp()
}
} else {
// TODO
}
}
}
}
}
data class CurrentPlayback(
val itemId: UUID,
val audioIndex: Int?,
val subtitleIndex: Int?,
val mediaSourceId: UUID?,
val tracks: List<TrackSupport>,
val playMethod: PlayMethod,
val playSessionId: String?,
)
private val Format.idAsInt: Int?
@OptIn(UnstableApi::class)
get() =
id?.let {
if (it.contains(":")) {
it.split(":").last().toIntOrNull()
} else {
it.toIntOrNull()
}
}
@OptIn(UnstableApi::class)
private fun applyTrackSelections(
player: Player,
source: MediaSourceInfo,
audioIndex: Int?,
subtitleIndex: Int?,
) {
if (source.supportsDirectPlay) {
if (subtitleIndex != null && subtitleIndex >= 0) {
val chosenTrack =
player.currentTracks.groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_TEXT && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(subtitleIndex + 1) // Indexes are 1 based
}
Timber.v("Chosen subtitle track: $chosenTrack")
chosenTrack?.let {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
).build()
}
} else {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
.build()
}
if (audioIndex != null) {
val chosenTrack =
player.currentTracks.groups.firstOrNull { group ->
group.type == C.TRACK_TYPE_AUDIO && group.isSupported &&
(0..<group.mediaTrackGroup.length)
.map {
group.getTrackFormat(it).idAsInt
}.contains(audioIndex + 1) // Indexes are 1 based
}
Timber.v("Chosen audio track: $chosenTrack")
chosenTrack?.let {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setOverrideForType(
TrackSelectionOverride(
chosenTrack.mediaTrackGroup,
0,
),
).build()
}
}
}
}

View file

@ -0,0 +1,233 @@
package com.github.damontecres.wholphin.ui.playback
/*
* Modified from https://github.com/android/tv-samples
*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.view.KeyEvent
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.handleDPadKeyEvents
import kotlinx.coroutines.FlowPreview
import kotlin.time.Duration
@Composable
fun SteppedSeekBarImpl(
progress: Float,
durationMs: Long,
bufferedProgress: Float,
onSeek: (Long) -> Unit,
controllerViewState: ControllerViewState,
modifier: Modifier = Modifier,
intervals: Int = 10,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
enabled: Boolean = true,
) {
val isFocused by interactionSource.collectIsFocusedAsState()
var hasSeeked by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(progress) }
val progressToUse = if (isFocused && hasSeeked) seekProgress else progress
LaunchedEffect(isFocused) {
if (!isFocused) hasSeeked = false
}
val offset = 1f / intervals
val seek = { percent: Float ->
onSeek((percent * durationMs).toLong())
}
SeekBarDisplay(
enabled = enabled,
progress = progressToUse,
bufferedProgress = bufferedProgress,
onLeft = {
controllerViewState.pulseControls()
seekProgress = (progressToUse - offset).coerceAtLeast(0f)
hasSeeked = true
seek(seekProgress)
},
onRight = {
controllerViewState.pulseControls()
seekProgress = (progressToUse + offset).coerceAtMost(1f)
hasSeeked = true
seek(seekProgress)
},
interactionSource = interactionSource,
modifier = modifier,
)
}
@OptIn(FlowPreview::class)
@Composable
fun IntervalSeekBarImpl(
progress: Float,
durationMs: Long,
bufferedProgress: Float,
onSeek: (Long) -> Unit,
controllerViewState: ControllerViewState,
seekBack: Duration,
seekForward: Duration,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
enabled: Boolean = true,
) {
val isFocused by interactionSource.collectIsFocusedAsState()
var hasSeeked by remember { mutableStateOf(false) }
var seekPositionMs by remember { mutableLongStateOf((progress * durationMs).toLong()) }
// val progressToUse by remember { derivedStateOf { if (isFocused && hasSeeked) seekPositionMs else (progress * durationMs).toLong() } }
val progressToUse =
if (isFocused && hasSeeked) seekPositionMs else (progress * durationMs).toLong()
LaunchedEffect(isFocused) {
if (!isFocused) hasSeeked = false
}
SeekBarDisplay(
enabled = enabled,
progress = (progressToUse.toDouble() / durationMs).toFloat(),
bufferedProgress = bufferedProgress,
onLeft = {
controllerViewState.pulseControls()
seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L)
hasSeeked = true
onSeek(seekPositionMs)
},
onRight = {
controllerViewState.pulseControls()
seekPositionMs =
(progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs)
hasSeeked = true
onSeek(seekPositionMs)
},
interactionSource = interactionSource,
modifier = modifier,
)
}
@Composable
fun SeekBarDisplay(
progress: Float,
bufferedProgress: Float,
onLeft: () -> Unit,
onRight: () -> Unit,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val color = MaterialTheme.colorScheme.border
val isFocused by interactionSource.collectIsFocusedAsState()
val animatedIndicatorHeight by animateDpAsState(
targetValue = 6.dp.times((if (isFocused) 2f else 1f)),
)
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Canvas(
modifier =
Modifier
.fillMaxWidth()
.height(animatedIndicatorHeight)
.padding(horizontal = 4.dp)
.onPreviewKeyEvent { event ->
val trigger =
event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0
when (event.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
if (trigger) onLeft.invoke()
return@onPreviewKeyEvent true
}
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
if (trigger) onRight.invoke()
return@onPreviewKeyEvent true
}
}
false
}.handleDPadKeyEvents(
onLeft = onLeft,
onRight = onRight,
).focusable(enabled = enabled, interactionSource = interactionSource),
onDraw = {
val yOffset = size.height.div(2)
drawLine(
color = color.copy(alpha = 0.15f),
start = Offset(x = 0f, y = yOffset),
end = Offset(x = size.width, y = yOffset),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawLine(
color = color.copy(alpha = 0.50f),
start = Offset(x = 0f, y = yOffset),
end =
Offset(
x = size.width.times(bufferedProgress),
y = yOffset,
),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawLine(
color = color,
start = Offset(x = 0f, y = yOffset),
end =
Offset(
// x = size.width.times(if (isSelected) seekProgress else progress),
x = size.width.times(progress),
y = yOffset,
),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
drawCircle(
color = Color.White,
radius = size.height + 2,
center = Offset(x = size.width.times(progress), y = yOffset),
)
},
)
}
}

View file

@ -0,0 +1,61 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.debounce
@UnstableApi
@Composable
fun rememberSeekBarState(
player: Player,
scope: CoroutineScope,
): SeekBarState {
val seekBarState = remember(player) { SeekBarState(player, scope) }
LaunchedEffect(player) {
seekBarState.observe()
}
return seekBarState
}
@UnstableApi
class SeekBarState(
private val player: Player,
private val scope: CoroutineScope,
) {
var isEnabled by mutableStateOf(player.isCommandAvailable(Player.COMMAND_SEEK_FORWARD))
private set
private val channel = Channel<Long>(CONFLATED)
fun onValueChange(positionMs: Long) {
channel.trySend(positionMs)
}
@OptIn(FlowPreview::class)
suspend fun observe() {
channel
.consumeAsFlow()
.debounce { 750L }
.collect {
player.seekTo(it)
}
}
// suspend fun observe(): Nothing =
// player.listen { events ->
// if (events.contains(Player.EVENT_AVAILABLE_COMMANDS_CHANGED)) {
// isEnabled = isCommandAvailable(Player.COMMAND_SEEK_FORWARD)
// }
// }
}

View file

@ -0,0 +1,139 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.transformations
import com.github.damontecres.wholphin.ui.CoilTrickplayTransformation
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.TrickplayInfo
import kotlin.time.Duration.Companion.seconds
fun Modifier.offsetByPercent(
xPercentage: Float,
yOffset: Int,
) = this.then(
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(
x =
((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2)
.coerceIn(0, constraints.maxWidth - placeable.width),
y = constraints.maxHeight - yOffset, // (constraints.maxHeight * yPercentage).toInt() - (placeable.height / 1.33f).toInt(),
)
}
},
)
/**
* Offset the composable by a percentage of the available x direction
*
* This will account for the composable actual width so it won't be pushed off screen.
* In other words, 0% means the left edge of the composable will be at the left end of the x-axis.
*
* @param xPercentage percent offset between 0 inclusive and 1 inclusive
*/
fun Modifier.offsetByPercent(xPercentage: Float) =
this.then(
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(
x =
((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2)
.coerceIn(0, constraints.maxWidth - placeable.width),
y = 0,
)
}
},
)
/**
* Show trickplay preview image. This composable assumes the provided URL is for the correct index.
*
* If no trickplay image is available, just the timestamp will be shown.
*/
@Composable
fun SeekPreviewImage(
previewImageUrl: String,
duration: Long,
seekProgressMs: Long,
videoWidth: Int?,
videoHeight: Int?,
trickPlayInfo: TrickplayInfo,
modifier: Modifier = Modifier,
placeHolder: Painter? = null,
) {
val context = LocalContext.current
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (previewImageUrl.isNotNullOrBlank() &&
videoWidth != null &&
videoHeight != null
) {
val height = 160.dp
val width = height * (videoWidth.toFloat() / videoHeight)
val heightPx = with(LocalDensity.current) { height.toPx().toInt() }
val widthPx = with(LocalDensity.current) { width.toPx().toInt() }
val index = (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Which tile
val numberOfTitlesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth
val imageIndex = index % numberOfTitlesPerImage
AsyncImage(
modifier =
Modifier
.width(width)
.height(height)
.background(Color.Black)
.border(1.5.dp, color = MaterialTheme.colorScheme.border),
model =
ImageRequest
.Builder(context)
.data(previewImageUrl)
.memoryCachePolicy(CachePolicy.ENABLED)
.transformations(
CoilTrickplayTransformation(
widthPx,
heightPx,
trickPlayInfo.tileHeight,
trickPlayInfo.tileWidth,
imageIndex,
index,
),
).build(),
contentScale = ContentScale.None,
contentDescription = null,
placeholder = placeHolder,
)
}
Text(
text = (seekProgressMs / 1000L).seconds.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
)
}
}

View file

@ -0,0 +1,67 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.ifElse
import kotlin.math.abs
@Composable
fun SkipIndicator(
durationMs: Long,
onFinish: () -> Unit,
modifier: Modifier = Modifier,
) {
val backward = durationMs < 0
var currentRotation by remember { mutableFloatStateOf(0f) }
val rotation = remember { Animatable(currentRotation) }
LaunchedEffect(durationMs, backward, onFinish) {
rotation.animateTo(
targetValue = currentRotation + if (backward) -270f else 270f,
animationSpec =
tween(
durationMillis = 800,
),
) {
currentRotation = value
}
onFinish()
}
Box(modifier = modifier.size(55.dp, 55.dp)) {
Image(
modifier =
Modifier
.fillMaxSize()
.rotate(currentRotation)
.ifElse(backward, Modifier.scale(scaleX = -1f, scaleY = 1f)),
painter = painterResource(id = R.drawable.circular_arrow_right),
contentDescription = null,
)
Text(
modifier = Modifier.align(Alignment.Center),
color = MaterialTheme.colorScheme.onBackground,
fontSize = 13.sp,
text = abs(durationMs / 1000).toString(),
)
}
}

View file

@ -0,0 +1,31 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.tv.material3.ListItem
@Composable
fun ClickPreference(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
ListItem(
selected = false,
onClick = onClick,
onLongClick = onLongClick,
headlineContent = {
PreferenceTitle(title)
},
supportingContent = {
PreferenceSummary(summary)
},
interactionSource = interactionSource,
modifier = modifier,
)
}

View file

@ -0,0 +1,399 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppChoicePreference
import com.github.damontecres.wholphin.preferences.AppClickablePreference
import com.github.damontecres.wholphin.preferences.AppDestinationPreference
import com.github.damontecres.wholphin.preferences.AppMultiChoicePreference
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppSliderPreference
import com.github.damontecres.wholphin.preferences.AppStringPreference
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
@Suppress("UNCHECKED_CAST")
@Composable
fun <T> ComposablePreference(
preference: AppPreference<T>,
value: T?,
onValueChange: (T) -> Unit,
onNavigate: (Destination) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var dialogParams by remember { mutableStateOf<DialogParams?>(null) }
var showStringDialog by remember { mutableStateOf<StringInput?>(null) }
val title = stringResource(preference.title)
val onClick: () -> Unit = {
scope.launch(ExceptionHandler()) {
when (preference) {
else -> {}
}
}
}
val onLongClick: () -> Unit = {
scope.launch(ExceptionHandler()) {
when (preference) {
else -> null
}
}
}
when (preference) {
is AppDestinationPreference ->
ClickPreference(
title = title,
onClick = {
onNavigate.invoke(preference.destination)
},
summary = preference.summary(context, value),
interactionSource = interactionSource,
modifier = modifier,
)
is AppClickablePreference ->
ClickPreference(
title = title,
onClick = onClick,
onLongClick = onLongClick,
summary = preference.summary(context, value),
interactionSource = interactionSource,
modifier = modifier,
)
is AppSwitchPreference ->
SwitchPreference(
title = title,
value = value as Boolean,
onClick = { onValueChange.invoke(!value as T) },
summary = preference.summary(context, value),
interactionSource = interactionSource,
modifier = modifier,
)
is AppStringPreference ->
ClickPreference(
title = title,
onClick = {
showStringDialog =
StringInput(
title = title,
value = value as String?,
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
// keyboardType =
// if (preference == AppPreference.UpdateUrl) {
// KeyboardType.Uri
// } else {
// KeyboardType.Unspecified
// },
imeAction = ImeAction.Done,
),
onSubmit = { input ->
onValueChange.invoke(input as T)
showStringDialog = null
},
)
},
summary =
preference.summary(context, value)
?: preference.summary?.let { stringResource(it) },
interactionSource = interactionSource,
modifier = modifier,
)
is AppChoicePreference -> {
val values = stringArrayResource(preference.displayValues).toList()
val summary =
preference.summary?.let { stringResource(it) }
?: preference.summary(context, value)
?: preference
.valueToIndex(value as T)
.let { values[it] }
val selectedIndex = remember(value) { preference.valueToIndex.invoke(value as T) }
ClickPreference(
title = title,
summary = summary,
onClick = {
dialogParams =
DialogParams(
title = title,
fromLongClick = false,
items =
values.mapIndexed { index, it ->
if (index == selectedIndex) {
DialogItem(
text = it,
icon = Icons.Default.Done,
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
} else {
DialogItem(
text = it,
onClick = {
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
}
},
)
},
interactionSource = interactionSource,
modifier = modifier,
)
}
is AppMultiChoicePreference<*> -> {
val values = stringArrayResource(preference.displayValues).toList()
val summary =
preference.summary?.let { stringResource(it) }
?: preference.summary(context, value)
val selectedValues =
remember {
val list = mutableStateSetOf<Any>()
list.addAll(value as List<Any>)
list
}
val onClick = { item: Any ->
if (selectedValues.contains(item)) {
selectedValues.remove(item)
} else {
selectedValues.add(item)
}
onValueChange.invoke(selectedValues.toList() as T)
}
ClickPreference(
title = title,
summary = summary,
onClick = {
dialogParams =
DialogParams(
title = title,
fromLongClick = false,
items =
values.mapIndexed { index, it ->
val item = preference.allValues[index]!!
DialogItem(
headlineContent = { Text(it) },
trailingContent = {
Switch(
checked = selectedValues.contains(item),
onCheckedChange = {
onClick.invoke(item)
},
)
},
onClick = {
onClick.invoke(item)
},
)
},
)
},
interactionSource = interactionSource,
modifier = modifier,
)
}
is AppSliderPreference -> {
val summary =
preference.summary(context, value)
?: preference.summary?.let { stringResource(it) }
SliderPreference(
preference = preference,
title = title,
summary = summary,
value = value as Long,
onChange = { onValueChange(it as T) },
summaryBelow = false,
interactionSource = interactionSource,
modifier = modifier,
)
}
}
val dialogBackgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
AnimatedVisibility(dialogParams != null) {
dialogParams?.let {
DialogPopup(
showDialog = true,
title = it.title,
dialogItems = it.items,
onDismissRequest = { dialogParams = null },
waitToLoad = false,
dismissOnClick = false,
)
}
}
AnimatedVisibility(showStringDialog != null) {
showStringDialog?.let {
var mutableValue by remember { mutableStateOf(it.value ?: "") }
val onDone = {
it.onSubmit.invoke(mutableValue)
}
Dialog(
onDismissRequest = { showStringDialog = null },
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.padding(16.dp)
.background(
color = dialogBackgroundColor,
shape = RoundedCornerShape(8.dp),
),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(16.dp),
) {
Text(
text = it.title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier,
)
EditTextBox(
value = mutableValue,
onValueChange = { mutableValue = it },
keyboardOptions = it.keyboardOptions.copy(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = { onDone.invoke() },
),
leadingIcon = null,
isInputValid = { true },
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.SpaceAround,
modifier = Modifier.fillMaxWidth(),
) {
Button(
onClick = { showStringDialog = null },
modifier = Modifier,
) {
Text(
text = stringResource(R.string.cancel),
)
}
Button(
onClick = onDone,
modifier = Modifier,
) {
Text(
text = stringResource(R.string.save),
)
}
}
}
}
}
}
}
}
val PreferenceTitleStyle: TextStyle
@Composable @ReadOnlyComposable
get() = MaterialTheme.typography.titleSmall
val PreferenceSummaryStyle: TextStyle
@Composable @ReadOnlyComposable
get() = MaterialTheme.typography.bodySmall
@Composable
fun PreferenceTitle(
title: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
) {
Text(
text = title,
style = PreferenceTitleStyle,
color = color,
modifier = modifier,
)
}
@Composable
fun PreferenceSummary(
summary: String?,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
) {
summary?.let {
Text(
text = it,
style = PreferenceSummaryStyle,
color = color,
modifier = modifier,
)
}
}
private data class StringInput(
val title: String,
val value: String?,
val keyboardOptions: KeyboardOptions,
val onSubmit: (String) -> Unit,
)

View file

@ -0,0 +1,36 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.preferences.AppPreference
import kotlinx.serialization.Serializable
/**
* A group of preferences
*/
data class PreferenceGroup(
@param:StringRes val title: Int,
val preferences: List<AppPreference<out Any?>>,
)
/**
* Results when validating a preference value.
*/
sealed interface PreferenceValidation {
data object Valid : PreferenceValidation
data class Invalid(
val message: String,
) : PreferenceValidation
}
@Serializable
enum class PreferenceScreenOption {
BASIC,
ADVANCED,
USER_INTERFACE,
;
companion object {
fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC
}
}

View file

@ -0,0 +1,329 @@
package com.github.damontecres.wholphin.ui.preferences
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.uiPreferences
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
@Composable
fun PreferencesContent(
initialPreferences: AppPreferences,
preferenceScreenOption: PreferenceScreenOption,
modifier: Modifier = Modifier,
viewModel: PreferencesViewModel = hiltViewModel(),
updateVM: UpdateViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) }
val state = rememberLazyListState()
var preferences by remember { mutableStateOf(initialPreferences) }
LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect {
preferences = it
}
}
val release by updateVM.release.observeAsState(null)
LaunchedEffect(Unit) {
if (preferences.autoCheckForUpdates) {
updateVM.init(preferences.updateUrl)
}
}
val movementSounds = true
val installedVersion = updateVM.currentVersion
val updateAvailable = release?.version?.isGreaterThan(installedVersion) ?: false
val prefList =
when (preferenceScreenOption) {
PreferenceScreenOption.BASIC -> basicPreferences
PreferenceScreenOption.ADVANCED -> advancedPreferences
PreferenceScreenOption.USER_INTERFACE -> uiPreferences
}
val screenTitle =
when (preferenceScreenOption) {
PreferenceScreenOption.BASIC -> "Preferences"
PreferenceScreenOption.ADVANCED -> "Advanced Preferences"
PreferenceScreenOption.USER_INTERFACE -> "User Interface Preferences"
}
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
// Forces the animated to trigger
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInHorizontally { it / 2 },
exit = fadeOut() + slideOutHorizontally { it / 2 },
modifier = modifier,
) {
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
LazyColumn(
state = state,
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(0.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
) {
stickyHeader {
Text(
text = screenTitle,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
)
}
if (preferenceScreenOption == PreferenceScreenOption.BASIC &&
preferences.autoCheckForUpdates &&
updateAvailable
) {
item {
val updateFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (focusedIndex.first == 0 && focusedIndex.second == 0) {
// Only re-focus if the user hasn't moved
updateFocusRequester.tryRequestFocus()
}
}
ClickPreference(
title = stringResource(R.string.install_update),
onClick = {
if (movementSounds) playOnClickSound(context)
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
},
summary = release?.version?.toString(),
modifier =
Modifier
.focusRequester(updateFocusRequester)
.playSoundOnFocus(movementSounds),
)
}
}
prefList.forEachIndexed { groupIndex, group ->
item {
Text(
text = stringResource(group.title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Start,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
)
}
group.preferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<Any>
item {
val interactionSource = remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(focused) {
if (focused) {
focusedIndex = Pair(groupIndex, prefIndex)
if (movementSounds) playOnClickSound(context)
}
}
when (pref) {
AppPreference.InstalledVersion -> {
var clickCount by remember { mutableIntStateOf(0) }
ClickPreference(
title = stringResource(R.string.installed_version),
onClick = {
if (movementSounds) playOnClickSound(context)
if (clickCount++ >= 2) {
clickCount = 0
// navigationManager.navigateTo(Destination.Debug)
}
},
summary = installedVersion.toString(),
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
),
)
}
AppPreference.Update -> {
ClickPreference(
title =
if (release != null && updateAvailable) {
stringResource(R.string.install_update)
} else if (!preferences.autoCheckForUpdates && release == null) {
stringResource(R.string.check_for_updates)
} else {
stringResource(R.string.no_update_available)
},
onClick = {
if (movementSounds) playOnClickSound(context)
if (release != null && updateAvailable) {
release?.let {
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
}
} else {
updateVM.init(preferences.updateUrl)
}
},
onLongClick = {
if (movementSounds) playOnClickSound(context)
viewModel.navigationManager.navigateTo(Destination.UpdateApp)
},
summary =
if (updateAvailable) {
release?.version?.toString()
} else {
null
},
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
),
)
}
AppPreference.ClearImageCache -> {
ClickPreference(
title = stringResource(pref.title),
onClick = {
SingletonImageLoader.get(context).let {
it.memoryCache?.clear()
it.diskCache?.clear()
}
},
modifier = Modifier,
summary = null,
onLongClick = {},
interactionSource = interactionSource,
)
}
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(
preference = pref,
value = value,
onNavigate = viewModel.navigationManager::navigateTo,
onValueChange = { newValue ->
val validation = pref.validate(newValue)
when (validation) {
is PreferenceValidation.Invalid -> {
// TODO?
Toast
.makeText(
context,
validation.message,
Toast.LENGTH_SHORT,
).show()
}
PreferenceValidation.Valid -> {
scope.launch(ExceptionHandler()) {
preferences =
viewModel.preferenceDataStore.updateData { prefs ->
pref.setter(prefs, newValue)
}
}
}
}
},
interactionSource = interactionSource,
modifier =
Modifier
.ifElse(
groupIndex == focusedIndex.first && prefIndex == focusedIndex.second,
Modifier.focusRequester(focusRequester),
),
)
}
}
}
}
}
}
}
}
@Composable
fun PreferencesPage(
initialPreferences: AppPreferences,
preferenceScreenOption: PreferenceScreenOption,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.background(MaterialTheme.colorScheme.background),
) {
PreferencesContent(
initialPreferences,
preferenceScreenOption,
Modifier
.fillMaxWidth(.4f)
.fillMaxHeight()
.align(Alignment.TopEnd),
)
}
}

View file

@ -0,0 +1,16 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class PreferencesViewModel
@Inject
constructor(
val preferenceDataStore: DataStore<AppPreferences>,
val navigationManager: NavigationManager,
) : ViewModel()

View file

@ -0,0 +1,93 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.contentColorFor
import com.github.damontecres.wholphin.preferences.AppSliderPreference
import com.github.damontecres.wholphin.ui.components.SliderBar
@Composable
fun SliderPreference(
preference: AppSliderPreference,
title: String,
summary: String?,
value: Long,
onChange: (Long) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
summaryBelow: Boolean = false,
heightAdjustment: Dp = 0.dp,
additionalSummary: @Composable (ColumnScope.() -> Unit)? = null,
) {
val focused = interactionSource.collectIsFocusedAsState().value
val background =
if (focused) {
MaterialTheme.colorScheme.inverseSurface
} else {
Color.Unspecified
}
val contentColor = contentColorFor(background)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
// .height(80.dp) // not dense
.height(72.dp + heightAdjustment) // dense
.fillMaxWidth()
.background(background, shape = RoundedCornerShape(8.dp))
.padding(PaddingValues(horizontal = 12.dp, vertical = 10.dp)), // dense,
) {
PreferenceTitle(title, color = contentColor)
ProvideTextStyle(PreferenceSummaryStyle.copy(color = contentColor)) {
additionalSummary?.invoke(this)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize(),
) {
SliderBar(
value = value,
min = preference.min,
max = preference.max,
interval = preference.interval,
onChange = onChange,
color = MaterialTheme.colorScheme.border,
interactionSource = interactionSource,
modifier = Modifier.weight(1f),
)
if (!summaryBelow) {
PreferenceSummary(summary, color = contentColor)
}
}
if (summaryBelow) {
PreferenceSummary(summary, color = contentColor)
}
}
}

View file

@ -0,0 +1,63 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.tv.material3.ListItem
import androidx.tv.material3.Switch
import androidx.tv.material3.SwitchDefaults
@Composable
fun SwitchPreference(
title: String,
value: Boolean,
onClick: () -> Unit,
summaryOn: String?,
summaryOff: String?,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = SwitchPreference(
title = title,
value = value,
onClick = onClick,
modifier = modifier,
summary = if (value) summaryOn else summaryOff,
onLongClick = onLongClick,
interactionSource = interactionSource,
)
@Composable
fun SwitchPreference(
title: String,
value: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
summary: String? = null,
onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
ListItem(
selected = false,
onClick = onClick,
onLongClick = onLongClick,
headlineContent = {
PreferenceTitle(title)
},
supportingContent = {
PreferenceSummary(summary)
},
trailingContent = {
Switch(
checked = value,
onCheckedChange = { onClick.invoke() },
colors =
SwitchDefaults
.colors(),
)
},
interactionSource = interactionSource,
modifier = modifier,
)
}

View file

@ -0,0 +1,263 @@
package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class UpdateViewModel
@Inject
constructor(
val updater: UpdateChecker,
val navigationManager: NavigationManager,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val release = MutableLiveData<Release?>(null)
val currentVersion = updater.getInstalledVersion()
fun init(updateUrl: String) {
loading.value = LoadingState.Loading
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) {
val release = updater.getLatestRelease(updateUrl)
withContext(Dispatchers.Main) {
this@UpdateViewModel.release.value = release
loading.value = LoadingState.Success
}
}
}
fun installRelease(release: Release) {
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to install update")) {
updater.installRelease(release)
}
}
}
@Composable
fun InstallUpdatePage(
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: UpdateViewModel = hiltViewModel(),
) {
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val release by viewModel.release.observeAsState(null)
LaunchedEffect(Unit) {
viewModel.init(preferences.appPreferences.updateUrl)
}
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state, modifier)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage(modifier)
LoadingState.Success ->
release?.let {
if (it.version.isGreaterThan(viewModel.currentVersion)) {
InstallUpdatePageContent(
currentVersion = viewModel.currentVersion,
release = it,
onInstallRelease = {
viewModel.installRelease(it)
},
onCancel = {
viewModel.navigationManager.goBack()
},
modifier = modifier,
)
} else {
Text(
text = "No update available",
)
}
}
}
}
@Composable
fun InstallUpdatePageContent(
currentVersion: Version,
release: Release,
onInstallRelease: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
val scrollAmount = 100f
val columnState = rememberLazyListState()
val scope = rememberCoroutineScope()
fun scroll(reverse: Boolean = false) {
scope.launch(ExceptionHandler()) {
columnState.scrollBy(if (reverse) -scrollAmount else scrollAmount)
}
}
val columnInteractionSource = remember { MutableInteractionSource() }
val columnFocused by columnInteractionSource.collectIsFocusedAsState()
val columnColor =
if (columnFocused) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
} else {
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
}
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.focusable(interactionSource = columnInteractionSource)
.fillMaxHeight()
.fillMaxWidth(.6f)
.background(
columnColor,
shape = RoundedCornerShape(16.dp),
).onKeyEvent {
if (it.type == KeyEventType.KeyUp) {
return@onKeyEvent false
}
if (it.key == Key.DirectionDown) {
scroll(false)
return@onKeyEvent true
}
if (it.key == Key.DirectionUp) {
scroll(true)
return@onKeyEvent true
}
return@onKeyEvent false
},
) {
item {
Text(
// TODO render markdown
text = release.notes.joinToString("\n\n") + (release.body ?: ""),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.fillMaxWidth()
.align(Alignment.CenterVertically)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), shape = RoundedCornerShape(16.dp))
.padding(16.dp),
) {
Text(
text = "Update available",
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "$currentVersion => " + release.version.toString(),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Button(
onClick = onInstallRelease,
) {
Text(
text = "Download & Update",
)
}
Button(
onClick = onCancel,
) {
Text(
text = "Cancel",
)
}
}
}
}
@PreviewTvSpec
@Composable
private fun InstallUpdatePageContentPreview() {
WholphinTheme {
InstallUpdatePageContent(
currentVersion = Version.fromString("v0.4.0"),
release =
Release(
version = Version.fromString("v0.5.3"),
downloadUrl = "https://url",
publishedAt = null,
body =
"Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " +
"ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " +
"tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " +
"Pulvinar vivamus fringilla lacus nec metus bibendum egestas. " +
"Iaculis massa nisl malesuada lacinia integer nunc posuere. " +
"Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora " +
"torquent per conubia nostra inceptos himenaeos.\n\n" +
"Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus " +
"ex sapien vitae pellentesque sem placerat. In id cursus mi pretium " +
"tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. " +
"Pulvinar vivamus fringilla lacus nec metus bibendum egestas. " +
"Iaculis massa nisl malesuada lacinia integer nunc posuere. " +
"Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora " +
"torquent per conubia nostra inceptos himenaeos.",
notes = listOf(),
),
onInstallRelease = {},
onCancel = {},
)
}
}

View file

@ -0,0 +1,136 @@
package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Icon
import androidx.tv.material3.IconButtonDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.JellyfinServer
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
sealed interface ServerConnectionStatus {
object Success : ServerConnectionStatus
object Pending : ServerConnectionStatus
data class Error(
val message: String?,
) : ServerConnectionStatus
}
/**
* Display a list of servers plus option to add a new one
*/
@Composable
fun ServerList(
servers: List<JellyfinServer>,
connectionStatus: Map<String, ServerConnectionStatus>,
onSwitchServer: (JellyfinServer) -> Unit,
onAddServer: () -> Unit,
onRemoveServer: (JellyfinServer) -> Unit,
allowAdd: Boolean,
allowDelete: Boolean,
modifier: Modifier = Modifier,
) {
var showDeleteDialog by remember { mutableStateOf<JellyfinServer?>(null) }
LazyColumn(modifier = modifier) {
items(servers) { server ->
val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending
ListItem(
enabled = status == ServerConnectionStatus.Success,
selected = false,
headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) },
supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) },
leadingContent = {
when (status) {
ServerConnectionStatus.Success -> {}
ServerConnectionStatus.Pending -> {
CircularProgress(
Modifier.size(IconButtonDefaults.MediumIconSize),
)
}
is ServerConnectionStatus.Error -> {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = status.message,
tint = MaterialTheme.colorScheme.error,
)
}
}
},
onClick = { onSwitchServer.invoke(server) },
onLongClick = {
if (allowDelete) {
showDeleteDialog = server
}
},
modifier = Modifier,
)
}
if (allowAdd) {
item {
HorizontalDivider()
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = "Add Server") },
leadingContent = {
Icon(
imageVector = Icons.Default.Add,
tint = Color.Green.copy(alpha = .8f),
contentDescription = null,
)
},
onClick = { onAddServer.invoke() },
modifier = Modifier,
)
}
}
}
showDeleteDialog?.let { server ->
DialogPopup(
showDialog = allowDelete,
title = server.name ?: server.url,
dialogItems =
listOf(
DialogItem("Switch", R.string.fa_arrow_left_arrow_right) {
onSwitchServer.invoke(server)
},
DialogItem(
"Delete",
Icons.Default.Delete,
Color.Red.copy(alpha = .8f),
) {
onRemoveServer.invoke(server)
},
),
onDismissRequest = { showDeleteDialog = null },
dismissOnClick = true,
waitToLoad = true,
properties = DialogProperties(),
elevation = 5.dp,
)
}
}

View file

@ -0,0 +1,237 @@
package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun SwitchServerContent(
modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = hiltViewModel(),
) {
val currentServer = viewModel.serverRepository.currentServer
val servers by viewModel.servers.observeAsState(listOf())
val serverStatus by viewModel.serverStatus.observeAsState(mapOf())
val discoveredServers by viewModel.discoveredServers.observeAsState(listOf())
var showAddServer by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.discoverServers()
}
Box(
modifier = modifier,
) {
Row(
modifier =
Modifier
.align(Alignment.Center)
.padding(32.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth()
.weight(1f)
.padding(16.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.dp),
),
) {
Text(
text = "Select Server",
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
ServerList(
servers = servers,
connectionStatus = serverStatus,
onSwitchServer = {
viewModel.addServer(it.url)
},
onAddServer = {
showAddServer = true
},
onRemoveServer = {
viewModel.removeServer(it)
},
allowAdd = true,
allowDelete = true,
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(16.dp),
),
)
}
// Discover
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth()
.weight(1f)
.padding(16.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.dp),
),
) {
Text(
text = "Discovered Servers",
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
if (discoveredServers.isEmpty()) {
Text(
text = "Searching...",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
} else {
ServerList(
servers = discoveredServers,
connectionStatus =
discoveredServers
.map { it.id }
.associateWith { ServerConnectionStatus.Success },
onSwitchServer = {
viewModel.addServer(it.url)
},
onAddServer = {},
onRemoveServer = {},
allowAdd = false,
allowDelete = false,
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(16.dp),
),
)
}
}
}
if (showAddServer) {
LaunchedEffect(Unit) {
viewModel.clearAddServerState()
}
val state by viewModel.addServerState.observeAsState(LoadingState.Pending)
var url by remember { mutableStateOf("") }
val submit = {
viewModel.addServer(url)
}
BasicDialog(
onDismissRequest = {
showAddServer = false
viewModel.clearAddServerState()
},
properties = DialogProperties(usePlatformDefaultWidth = false),
elevation = 10.dp,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(16.dp)
.fillMaxWidth(.4f),
) {
Text(
text = "Enter Server URL",
)
EditTextBox(
value = url,
onValueChange = {
url = it
viewModel.clearAddServerState()
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Uri,
),
keyboardActions =
KeyboardActions(
onDone = { submit.invoke() },
),
modifier =
Modifier
.focusRequester(focusRequester)
.fillMaxWidth(),
)
when (val st = state) {
is LoadingState.Error -> {
Text(
text =
st.message ?: st.exception?.localizedMessage
?: "An error occurred",
color = MaterialTheme.colorScheme.error,
)
}
else -> {}
}
Button(
onClick = { submit.invoke() },
enabled = url.isNotNullOrBlank() && state == LoadingState.Pending,
modifier = Modifier,
) {
if (state == LoadingState.Loading) {
CircularProgress(Modifier.size(32.dp))
} else {
Text(text = "Submit")
}
}
}
}
}
}
}

View file

@ -0,0 +1,253 @@
package com.github.damontecres.wholphin.ui.setup
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun SwitchUserContent(
modifier: Modifier = Modifier,
viewModel: SwitchUserViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val currentServer = viewModel.serverRepository.currentServer
val currentUser = viewModel.serverRepository.currentUser
val users by viewModel.users.observeAsState(listOf())
val serverQuickConnect by viewModel.serverQuickConnect.observeAsState(mapOf())
val quickConnectEnabled = currentServer?.let { serverQuickConnect[it.id] ?: false } ?: false
val quickConnect by viewModel.quickConnectState.observeAsState(null)
var showAddUser by remember { mutableStateOf(false) }
val userState by viewModel.switchUserState.observeAsState(LoadingState.Pending)
LaunchedEffect(userState) {
if (!showAddUser) {
when (val s = userState) {
is LoadingState.Error -> {
val msg = s.message ?: s.exception?.localizedMessage
Toast.makeText(context, "Error: $msg", Toast.LENGTH_LONG).show()
}
else -> {}
}
}
}
currentServer?.let { server ->
Box(
modifier = modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth(.5f)
.align(Alignment.Center)
.padding(16.dp)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(16.dp),
),
) {
Text(
text = "Select User",
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = server.name ?: server.url,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
UserList(
users = users,
currentUser = currentUser,
onSwitchUser = { user ->
viewModel.switchUser(server, user)
},
onAddUser = { showAddUser = true },
onRemoveUser = { user ->
viewModel.removeUser(user)
},
onSwitchServer = {
viewModel.navigationManager.navigateTo(Destination.ServerList)
},
modifier =
Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
shape = RoundedCornerShape(16.dp),
),
)
}
}
if (showAddUser) {
var useQuickConnect by remember { mutableStateOf(quickConnectEnabled) }
LaunchedEffect(Unit) {
viewModel.clearSwitchUserState()
if (useQuickConnect) {
viewModel.initiateQuickConnect(server) {
viewModel.navigationManager.goToHome()
}
}
}
BasicDialog(
onDismissRequest = {
viewModel.cancelQuickConnect()
showAddUser = false
},
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(16.dp)
.fillMaxWidth(),
) {
if (useQuickConnect) {
quickConnect?.let { qc ->
Text(
text = "Use Quick Connect on your device to authenticate to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = qc.code,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Button(
onClick = {
useQuickConnect = false
},
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(text = "Use username/password")
}
}
} else {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val onSubmit = {
viewModel.login(server, username, password)
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Text(
text = "Enter username/password to login to ${server.name ?: server.url}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup(),
) {
Text(
text = "Username",
modifier = Modifier.padding(end = 8.dp),
)
EditTextBox(
value = username,
onValueChange = {
username = it
viewModel.clearSwitchUserState()
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Text,
),
modifier = Modifier.focusRequester(focusRequester),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
Text(
text = "Password",
modifier = Modifier.padding(end = 8.dp),
)
EditTextBox(
value = password,
onValueChange = {
password = it
viewModel.clearSwitchUserState()
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password,
),
keyboardActions =
KeyboardActions(
onDone = { onSubmit.invoke() },
),
modifier = Modifier,
)
}
Button(
onClick = { onSubmit.invoke() },
enabled = username.isNotNullOrBlank() && password.isNotNullOrBlank(),
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text("Login")
}
}
when (val s = userState) {
is LoadingState.Error -> {
Text(
text = s.message ?: s.exception?.localizedMessage ?: "Error",
color = MaterialTheme.colorScheme.error,
)
}
else -> {}
}
}
}
}
}
}

View file

@ -0,0 +1,306 @@
package com.github.damontecres.wholphin.ui.setup
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.JellyfinServer
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.JellyfinUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.QuickConnectDto
import org.jellyfin.sdk.model.api.QuickConnectResult
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class SwitchUserViewModel
@Inject
constructor(
val jellyfin: Jellyfin,
val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao,
val navigationManager: NavigationManager,
) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<String, ServerConnectionStatus>>(mapOf())
val serverQuickConnect = MutableLiveData<Map<String, Boolean>>(mapOf())
val users = MutableLiveData<List<JellyfinUser>>(listOf())
val quickConnectState = MutableLiveData<QuickConnectResult?>(null)
private var quickConnectJob: Job? = null
val discoveredServers = MutableLiveData<List<JellyfinServer>>(listOf())
val addServerState = MutableLiveData<LoadingState>(LoadingState.Pending)
val switchUserState = MutableLiveData<LoadingState>(LoadingState.Pending)
fun clearAddServerState() {
addServerState.value = LoadingState.Pending
}
fun clearSwitchUserState() {
switchUserState.value = LoadingState.Pending
}
init {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val allServers =
serverDao
.getServers()
.map { it.server }
.sortedWith(compareBy<JellyfinServer> { it.name }.thenBy { it.url })
withContext(Dispatchers.Main) {
servers.value = allServers
}
allServers.forEach { server ->
try {
jellyfin
.createApi(
server.url,
httpClientOptions =
HttpClientOptions(
requestTimeout = 6.seconds,
connectTimeout = 6.seconds,
socketTimeout = 6.seconds,
),
).systemApi
.getPublicSystemInfo()
val quickConnect by
jellyfin
.createApi(server.url)
.quickConnectApi
.getQuickConnectEnabled()
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Success,
)
}
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(server.id, quickConnect)
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error checking quick connect for server ${server.url}")
withContext(Dispatchers.Main) {
serverStatus.value =
serverStatus.value!!.toMutableMap().apply {
put(
server.id,
ServerConnectionStatus.Error(ex.localizedMessage),
)
}
}
}
}
}
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
serverRepository.currentServer?.let {
val quickConnect =
jellyfin
.createApi(it.url)
.quickConnectApi
.getQuickConnectEnabled()
.content
val serverUsers = serverDao.getServer(it.id)?.users?.sortedBy { it.name } ?: listOf()
withContext(Dispatchers.Main) {
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(it.id, quickConnect)
}
users.value = serverUsers
}
}
}
}
fun switchUser(
server: JellyfinServer,
user: JellyfinUser,
) {
viewModelScope.launch(ExceptionHandler()) {
try {
serverRepository.changeUser(server, user)
navigationManager.goToHome()
} catch (ex: Exception) {
switchUserState.value = LoadingState.Error(exception = ex)
}
}
}
fun login(
server: JellyfinServer,
username: String,
password: String,
) {
quickConnectJob?.cancel()
viewModelScope.launch(ExceptionHandler()) {
try {
val api = jellyfin.createApi(baseUrl = server.url)
val authenticationResult by api.userApi.authenticateUserByName(
username = username,
password = password,
)
serverRepository.changeUser(server.url, authenticationResult)
withContext(Dispatchers.Main) {
navigationManager.goToHome()
}
} catch (ex: Exception) {
switchUserState.value = LoadingState.Error(ex)
}
}
}
fun initiateQuickConnect(
server: JellyfinServer,
onAuthenticated: () -> Unit,
) {
quickConnectJob?.cancel()
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
try {
val api = jellyfin.createApi(server.url)
var state =
api
.quickConnectApi
.initiateQuickConnect()
.content
withContext(Dispatchers.Main) {
quickConnectState.value = state
}
quickConnectJob =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
while (!state.authenticated) {
delay(5_000L)
state =
api.quickConnectApi
.getQuickConnectState(
secret = state.secret,
).content
withContext(Dispatchers.Main) {
quickConnectState.value = state
}
}
val authenticationResult by api.userApi.authenticateWithQuickConnect(
QuickConnectDto(secret = state.secret),
)
serverRepository.changeUser(server.url, authenticationResult)
withContext(Dispatchers.Main) {
onAuthenticated.invoke()
}
}
} catch (ex: Exception) {
if (ex is InvalidStatusException && ex.status == 401) {
quickConnectState.value = null
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(server.id, false)
}
}
switchUserState.value = LoadingState.Error(ex)
}
}
}
fun cancelQuickConnect() {
quickConnectJob?.cancel()
quickConnectState.value = null
}
fun addServer(serverUrl: String) {
addServerState.value = LoadingState.Loading
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
try {
val serverInfo by jellyfin
.createApi(serverUrl)
.systemApi
.getPublicSystemInfo()
val id = serverInfo.id
if (id != null && serverInfo.startupWizardCompleted == true) {
serverRepository.addAndChangeServer(
JellyfinServer(
id = id,
name = serverInfo.serverName,
url = serverUrl,
),
)
val quickConnect =
jellyfin
.createApi(serverUrl)
.quickConnectApi
.getQuickConnectEnabled()
.content
withContext(Dispatchers.Main) {
serverQuickConnect.value =
serverQuickConnect.value!!.toMutableMap().apply {
put(id, quickConnect)
}
}
withContext(Dispatchers.Main) {
addServerState.value = LoadingState.Success
navigationManager.navigateTo(Destination.UserList)
}
} else {
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error("Server returned invalid response")
}
}
} catch (ex: Exception) {
Timber.w(ex, "Error creating API for $serverUrl")
withContext(Dispatchers.Main) {
addServerState.value =
LoadingState.Error(exception = ex)
}
}
}
}
fun removeUser(user: JellyfinUser) {
viewModelScope.launch(ExceptionHandler()) {
serverRepository.removeUser(user)
}
}
fun removeServer(server: JellyfinServer) {
viewModelScope.launch(ExceptionHandler()) {
serverRepository.removeServer(server)
}
}
fun discoverServers() {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
jellyfin.discovery.discoverLocalServers().collect { server ->
val newServerList =
discoveredServers.value!!
.toMutableList()
.apply { add(JellyfinServer(server.id, server.name, server.address)) }
withContext(Dispatchers.Main) {
discoveredServers.value = newServerList
}
}
}
}
}

View file

@ -0,0 +1,123 @@
package com.github.damontecres.wholphin.ui.setup
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.JellyfinUser
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup
/**
* Display a list of users plus option to add a new one or switch servers
*/
@Composable
fun UserList(
users: List<JellyfinUser>,
currentUser: JellyfinUser?,
onSwitchUser: (JellyfinUser) -> Unit,
onAddUser: () -> Unit,
onRemoveUser: (JellyfinUser) -> Unit,
onSwitchServer: () -> Unit,
modifier: Modifier = Modifier,
) {
var showDeleteDialog by remember { mutableStateOf<JellyfinUser?>(null) }
LazyColumn(modifier = modifier) {
items(users) { user ->
ListItem(
enabled = true,
selected = user == currentUser,
headlineContent = { Text(text = user.name ?: user.id) },
leadingContent = {
if (user.id == currentUser?.id) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "current user",
)
}
},
onClick = { onSwitchUser.invoke(user) },
onLongClick = {
showDeleteDialog = user
},
modifier = Modifier,
)
}
item {
HorizontalDivider()
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = "Add User") },
leadingContent = {
Icon(
imageVector = Icons.Default.Add,
tint = Color.Green.copy(alpha = .8f),
contentDescription = null,
)
},
onClick = { onAddUser.invoke() },
modifier = Modifier,
)
}
item {
HorizontalDivider()
ListItem(
enabled = true,
selected = false,
headlineContent = { Text(text = "Switch servers") },
leadingContent = {
Text(
text = stringResource(R.string.fa_arrow_left_arrow_right),
fontFamily = FontAwesome,
)
},
onClick = { onSwitchServer.invoke() },
modifier = Modifier,
)
}
}
showDeleteDialog?.let { user ->
DialogPopup(
showDialog = true,
title = user.name ?: user.id,
dialogItems =
listOf(
DialogItem("Switch", R.string.fa_arrow_left_arrow_right) {
onSwitchUser.invoke(user)
},
DialogItem(
"Delete",
Icons.Default.Delete,
Color.Red.copy(alpha = .8f),
) {
onRemoveUser.invoke(user)
},
),
onDismissRequest = { showDeleteDialog = null },
dismissOnClick = true,
waitToLoad = true,
properties = DialogProperties(),
elevation = 5.dp,
)
}
}

View file

@ -0,0 +1,59 @@
package com.github.damontecres.wholphin.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.BlueThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.GreenThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.OrangeThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
@Immutable
data class ColorFamily(
val color: Color,
val onColor: Color,
val colorContainer: Color,
val onColorContainer: Color,
)
val unspecified_scheme =
ColorFamily(
Color.Unspecified,
Color.Unspecified,
Color.Unspecified,
Color.Unspecified,
)
@Composable
fun WholphinTheme(
darkTheme: Boolean = true,
appThemeColors: AppThemeColors = AppThemeColors.PURPLE,
content: @Composable () -> Unit,
) {
val themeColors =
when (appThemeColors) {
AppThemeColors.PURPLE -> PurpleThemeColors
AppThemeColors.BLUE -> BlueThemeColors
AppThemeColors.GREEN -> GreenThemeColors
AppThemeColors.ORANGE -> OrangeThemeColors
AppThemeColors.UNRECOGNIZED -> PurpleThemeColors
}
val colorScheme =
when {
darkTheme -> themeColors.darkScheme
else -> themeColors.lightScheme
}
androidx.compose.material3.MaterialTheme(
colorScheme = if (darkTheme) themeColors.darkSchemeMaterial else themeColors.lightSchemeMaterial,
typography = androidx.compose.material3.Typography(),
) {
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content,
)
}
}

View file

@ -0,0 +1,8 @@
package com.github.damontecres.wholphin.ui.theme
interface ThemeColors {
val lightSchemeMaterial: androidx.compose.material3.ColorScheme
val darkSchemeMaterial: androidx.compose.material3.ColorScheme
val lightScheme: androidx.tv.material3.ColorScheme
val darkScheme: androidx.tv.material3.ColorScheme
}

View file

@ -0,0 +1,5 @@
package com.github.damontecres.wholphin.ui.theme
import androidx.tv.material3.Typography
val AppTypography = Typography()

View file

@ -0,0 +1,203 @@
package com.github.damontecres.wholphin.ui.theme.colors
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.darkColorScheme
import androidx.tv.material3.lightColorScheme
import com.github.damontecres.wholphin.ui.theme.ThemeColors
val BlueThemeColors =
object : ThemeColors {
val primaryLight = Color(0xFF405F90)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFD6E3FF)
val onPrimaryContainerLight = Color(0xFF274777)
val secondaryLight = Color(0xFF565F71)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFDAE2F9)
val onSecondaryContainerLight = Color(0xFF3E4759)
val tertiaryLight = Color(0xFF605690)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFE6DEFF)
val onTertiaryContainerLight = Color(0xFF483F77)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF93000A)
val backgroundLight = Color(0xFFF9F9FF)
val onBackgroundLight = Color(0xFF191C20)
val surfaceLight = Color(0xFFFAF8FF)
val onSurfaceLight = Color(0xFF1A1B21)
val surfaceVariantLight = Color(0xFFE1E2EC)
val onSurfaceVariantLight = Color(0xFF44474F)
val outlineLight = Color(0xFF75777F)
val outlineVariantLight = Color(0xFFC4C6D0)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF2F3036)
val inverseOnSurfaceLight = Color(0xFFF1F0F7)
val inversePrimaryLight = Color(0xFFAAC7FF)
val surfaceDimLight = Color(0xFFDAD9E0)
val surfaceBrightLight = Color(0xFFFAF8FF)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF4F3FA)
val surfaceContainerLight = Color(0xFFEEEDF4)
val surfaceContainerHighLight = Color(0xFFE8E7EF)
val surfaceContainerHighestLight = Color(0xFFE3E2E9)
val primaryDark = Color(0xFFAAC7FF)
val onPrimaryDark = Color(0xFF09305F)
val primaryContainerDark = Color(0xFF274777)
val onPrimaryContainerDark = Color(0xFFD6E3FF)
val secondaryDark = Color(0xFFBEC7DC)
val onSecondaryDark = Color(0xFF283141)
val secondaryContainerDark = Color(0xFF3E4759)
val onSecondaryContainerDark = Color(0xFFDAE2F9)
val tertiaryDark = Color(0xFFCABEFF)
val onTertiaryDark = Color(0xFF31285F)
val tertiaryContainerDark = Color(0xFF483F77)
val onTertiaryContainerDark = Color(0xFFE6DEFF)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF111318)
val onBackgroundDark = Color(0xFFE2E2E9)
val surfaceDark = Color(0xFF121318)
val onSurfaceDark = Color(0xFFE3E2E9)
val surfaceVariantDark = Color(0xFF44474F)
val onSurfaceVariantDark = Color(0xFFC4C6D0)
val outlineDark = Color(0xFF8E9099)
val outlineVariantDark = Color(0xFF44474F)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFE3E2E9)
val inverseOnSurfaceDark = Color(0xFF2F3036)
val inversePrimaryDark = Color(0xFF405F90)
val surfaceDimDark = Color(0xFF121318)
val surfaceBrightDark = Color(0xFF38393F)
val surfaceContainerLowestDark = Color(0xFF0D0E13)
val surfaceContainerLowDark = Color(0xFF1A1B21)
val surfaceContainerDark = Color(0xFF1E1F25)
val surfaceContainerHighDark = Color(0xFF292A2F)
val surfaceContainerHighestDark = Color(0xFF33343A)
override val lightSchemeMaterial: androidx.compose.material3.ColorScheme =
androidx.compose.material3.lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
)
override val lightScheme =
lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
border = inversePrimaryLight,
)
override val darkSchemeMaterial =
androidx.compose.material3.darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
)
override val darkScheme =
darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
border = inversePrimaryDark,
)
}

View file

@ -0,0 +1,203 @@
package com.github.damontecres.wholphin.ui.theme.colors
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.darkColorScheme
import androidx.tv.material3.lightColorScheme
import com.github.damontecres.wholphin.ui.theme.ThemeColors
val GreenThemeColors =
object : ThemeColors {
val primaryLight = Color(0xFF316A42)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFB3F1BF)
val onPrimaryContainerLight = Color(0xFF16512C)
val secondaryLight = Color(0xFF506352)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFD2E8D3)
val onSecondaryContainerLight = Color(0xFF394B3C)
val tertiaryLight = Color(0xFF3A656E)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFBDEAF5)
val onTertiaryContainerLight = Color(0xFF204D56)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF93000A)
val backgroundLight = Color(0xFFF6FBF3)
val onBackgroundLight = Color(0xFF181D18)
val surfaceLight = Color(0xFFF6FBF3)
val onSurfaceLight = Color(0xFF181D18)
val surfaceVariantLight = Color(0xFFDDE5DA)
val onSurfaceVariantLight = Color(0xFF414941)
val outlineLight = Color(0xFF717971)
val outlineVariantLight = Color(0xFFC1C9BF)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF2D322D)
val inverseOnSurfaceLight = Color(0xFFEEF2EA)
val inversePrimaryLight = Color(0xFF98D5A4)
val surfaceDimLight = Color(0xFFD7DBD4)
val surfaceBrightLight = Color(0xFFF6FBF3)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF0F5ED)
val surfaceContainerLight = Color(0xFFEBEFE7)
val surfaceContainerHighLight = Color(0xFFE5EAE2)
val primaryDark = Color(0xFF98D5A4)
val onPrimaryDark = Color(0xFF00391A)
val primaryContainerDark = Color(0xFF16512C)
val onPrimaryContainerDark = Color(0xFFB3F1BF)
val secondaryDark = Color(0xFFB7CCB7)
val onSecondaryDark = Color(0xFF223526)
val secondaryContainerDark = Color(0xFF394B3C)
val onSecondaryContainerDark = Color(0xFFD2E8D3)
val tertiaryDark = Color(0xFFA2CED9)
val onTertiaryDark = Color(0xFF01363F)
val tertiaryContainerDark = Color(0xFF204D56)
val onTertiaryContainerDark = Color(0xFFBDEAF5)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF101510)
val onBackgroundDark = Color(0xFFDFE4DC)
val surfaceDark = Color(0xFF101510)
val onSurfaceDark = Color(0xFFDFE4DC)
val surfaceVariantDark = Color(0xFF414941)
val onSurfaceVariantDark = Color(0xFFC1C9BF)
val outlineDark = Color(0xFF8B938A)
val outlineVariantDark = Color(0xFF414941)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFDFE4DC)
val inverseOnSurfaceDark = Color(0xFF2D322D)
val inversePrimaryDark = Color(0xFF316A42)
val surfaceDimDark = Color(0xFF101510)
val surfaceBrightDark = Color(0xFF353A35)
val surfaceContainerLowestDark = Color(0xFF0B0F0B)
val surfaceContainerLowDark = Color(0xFF181D18)
val surfaceContainerDark = Color(0xFF1C211C)
val surfaceContainerHighDark = Color(0xFF262B26)
val surfaceContainerHighestDark = Color(0xFF313631)
override val lightSchemeMaterial: ColorScheme =
androidx.compose.material3.lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
)
override val lightScheme =
lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
border = inversePrimaryLight,
)
override val darkSchemeMaterial =
androidx.compose.material3.darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
)
override val darkScheme =
darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
border = inversePrimaryDark.copy(alpha = .75f),
)
}

View file

@ -0,0 +1,204 @@
package com.github.damontecres.wholphin.ui.theme.colors
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.darkColorScheme
import androidx.tv.material3.lightColorScheme
import com.github.damontecres.wholphin.ui.theme.ThemeColors
val OrangeThemeColors =
object : ThemeColors {
val primaryLight = Color(0xFF725C0C)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFFFE086)
val onPrimaryContainerLight = Color(0xFF574500)
val secondaryLight = Color(0xFF685E3F)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFF1E1BB)
val onSecondaryContainerLight = Color(0xFF50462A)
val tertiaryLight = Color(0xFF855317)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFFFDCBD)
val onTertiaryContainerLight = Color(0xFF693C00)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF93000A)
val backgroundLight = Color(0xFFFFF8F0)
val onBackgroundLight = Color(0xFF1E1B13)
val surfaceLight = Color(0xFFFFF8F0)
val onSurfaceLight = Color(0xFF1E1B13)
val surfaceVariantLight = Color(0xFFEBE2CF)
val onSurfaceVariantLight = Color(0xFF4C4639)
val outlineLight = Color(0xFF7D7667)
val outlineVariantLight = Color(0xFFCEC6B4)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF343027)
val inverseOnSurfaceLight = Color(0xFFF8F0E2)
val inversePrimaryLight = Color(0xFFE1C46D)
val surfaceDimLight = Color(0xFFE1D9CC)
val surfaceBrightLight = Color(0xFFFFF8F0)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFFBF3E5)
val surfaceContainerLight = Color(0xFFF5EDDF)
val surfaceContainerHighLight = Color(0xFFEFE7D9)
val surfaceContainerHighestLight = Color(0xFFEAE2D4)
val primaryDark = Color(0xFFE1C46D)
val onPrimaryDark = Color(0xFF3C2F00)
val primaryContainerDark = Color(0xFF574500)
val onPrimaryContainerDark = Color(0xFFFFE086)
val secondaryDark = Color(0xFFD4C5A1)
val onSecondaryDark = Color(0xFF383016)
val secondaryContainerDark = Color(0xFF50462A)
val onSecondaryContainerDark = Color(0xFFF1E1BB)
val tertiaryDark = Color(0xFFFCB974)
val onTertiaryDark = Color(0xFF492900)
val tertiaryContainerDark = Color(0xFF693C00)
val onTertiaryContainerDark = Color(0xFFFFDCBD)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF16130B)
val onBackgroundDark = Color(0xFFEAE2D4)
val surfaceDark = Color(0xFF16130B)
val onSurfaceDark = Color(0xFFEAE2D4)
val surfaceVariantDark = Color(0xFF4C4639)
val onSurfaceVariantDark = Color(0xFFCEC6B4)
val outlineDark = Color(0xFF989080)
val outlineVariantDark = Color(0xFF4C4639)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFEAE2D4)
val inverseOnSurfaceDark = Color(0xFF343027)
val inversePrimaryDark = Color(0xFF725C0C)
val surfaceDimDark = Color(0xFF16130B)
val surfaceBrightDark = Color(0xFF3D392F)
val surfaceContainerLowestDark = Color(0xFF110E07)
val surfaceContainerLowDark = Color(0xFF1E1B13)
val surfaceContainerDark = Color(0xFF231F17)
val surfaceContainerHighDark = Color(0xFF2D2A21)
val surfaceContainerHighestDark = Color(0xFF38342B)
override val lightSchemeMaterial: ColorScheme =
androidx.compose.material3.lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
)
override val lightScheme =
lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
border = inversePrimaryLight,
)
override val darkSchemeMaterial =
androidx.compose.material3.darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
)
override val darkScheme =
darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
border = primaryDark,
)
}

View file

@ -0,0 +1,185 @@
package com.github.damontecres.wholphin.ui.theme.colors
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.darkColorScheme
import androidx.tv.material3.lightColorScheme
import com.github.damontecres.wholphin.ui.theme.ThemeColors
val PurpleThemeColors =
object : ThemeColors {
val primaryLight = Color(0xFF5F00D6)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFF7A28FE)
val onPrimaryContainerLight = Color(0xFFEADCFF)
val secondaryLight = Color(0xFF6B4BAE)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFB594FD)
val onSecondaryContainerLight = Color(0xFF472489)
val tertiaryLight = Color(0xFF8F007E)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFB800A3)
val onTertiaryContainerLight = Color(0xFFFFD7F0)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF93000A)
val backgroundLight = Color(0xFFFEF7FF)
val onBackgroundLight = Color(0xFF1D1A25)
val surfaceLight = Color(0xFFFEF7FF)
val onSurfaceLight = Color(0xFF1D1A25)
val surfaceVariantLight = Color(0xFFE9DEF6)
val onSurfaceVariantLight = Color(0xFF4A4456)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF332E3A)
val inverseOnSurfaceLight = Color(0xFFF6EDFE)
val inversePrimaryLight = Color(0xFFD2BCFF)
val primaryDark = Color(0xFFD2BCFF)
val onPrimaryDark = Color(0xFF3D008F)
val primaryContainerDark = Color(0xFF7A28FE)
val onPrimaryContainerDark = Color(0xFFEADCFF)
val secondaryDark = Color(0xFFD2BCFF)
val onSecondaryDark = Color(0xFF3B167D)
val secondaryContainerDark = Color(0xFF533295)
val onSecondaryContainerDark = Color(0xFFC2A6FF)
val tertiaryDark = Color(0xFFC071F8)
val onTertiaryDark = Color(0xFF5E0052)
val tertiaryContainerDark = Color(0xFFB800A3)
val onTertiaryContainerDark = Color(0xFFFFD7F0)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF15121C)
val onBackgroundDark = Color(0xFFE8DFF0)
val surfaceDark = Color(0xFF15121C)
val onSurfaceDark = Color(0xFFE8DFF0)
val surfaceVariantDark = Color(0xFF4A4456)
val onSurfaceVariantDark = Color(0xFFCCC3D9)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFE8DFF0)
val inverseOnSurfaceDark = Color(0xFF332E3A)
val inversePrimaryDark = Color(0xFF741BF8)
override val lightSchemeMaterial: androidx.compose.material3.ColorScheme =
androidx.compose.material3.lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
)
override val lightScheme =
lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
border = inversePrimaryLight,
)
override val darkSchemeMaterial =
androidx.compose.material3.darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
)
override val darkScheme =
darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
border = inversePrimaryDark.copy(alpha = .75f),
)
}