mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-10 00:21:19 +02:00
Rebrand
This commit is contained in:
parent
aabd8462dc
commit
d39ed721d5
138 changed files with 738 additions and 738 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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]) },
|
||||
)
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
// )
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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" }
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.github.damontecres.wholphin.ui.theme
|
||||
|
||||
import androidx.tv.material3.Typography
|
||||
|
||||
val AppTypography = Typography()
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue