Dependency updates & removing unused code (#70)

Just bumping dependencies and deleting some unused code. No user facing
changes
This commit is contained in:
damontecres 2025-10-26 18:17:47 -04:00 committed by GitHub
parent 1c1e64a5b9
commit 89b9364807
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 367 additions and 844 deletions

View file

@ -5,7 +5,6 @@ import android.os.Build
import android.util.Log
import androidx.compose.runtime.Composer
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
import androidx.compose.runtime.tooling.ComposeStackTraceMode
import dagger.hilt.android.HiltAndroidApp
import org.acra.ACRA
import org.acra.ReportField
@ -42,9 +41,7 @@ class WholphinApplication : Application() {
)
}
Composer.setDiagnosticStackTraceMode(
if (BuildConfig.DEBUG) ComposeStackTraceMode.SourceInformation else ComposeStackTraceMode.GroupKeys,
)
Composer.setDiagnosticStackTraceEnabled(BuildConfig.DEBUG)
}
override fun onCreate() {

View file

@ -8,6 +8,8 @@ import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID

View file

@ -5,6 +5,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinUser
import java.util.UUID
@Dao

View file

@ -1,66 +1,19 @@
package com.github.damontecres.wholphin.data
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Relation
import androidx.room.Transaction
import androidx.room.Update
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.data.model.JellyfinUser
import java.util.UUID
@Entity(tableName = "servers")
data class JellyfinServer(
@PrimaryKey val id: UUID,
val name: String?,
val url: String,
)
@Entity(
tableName = "users",
foreignKeys = [
ForeignKey(
entity = JellyfinServer::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("serverId"),
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("id", "serverId", unique = true)],
)
data class JellyfinUser(
@PrimaryKey(autoGenerate = true)
val rowId: Int = 0,
@ColumnInfo(index = true)
val id: UUID,
val name: String?,
@ColumnInfo(index = true)
val serverId: UUID,
val accessToken: String?,
) {
override fun toString(): String =
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken=${accessToken.isNotNullOrBlank()})"
}
data class JellyfinServerUsers(
@Embedded val server: JellyfinServer,
@Relation(
parentColumn = "id",
entityColumn = "serverId",
)
val users: List<JellyfinUser>,
)
@Dao
interface JellyfinServerDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
fun addServer(server: JellyfinServer): Long
@Update
@ -74,7 +27,7 @@ interface JellyfinServerDao {
}
}
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
fun addUser(user: JellyfinUser): Long
@Update

View file

@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
@Dao

View file

@ -7,6 +7,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.toServerString
import dagger.hilt.android.qualifiers.ApplicationContext

View file

@ -4,13 +4,14 @@ package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
import com.github.damontecres.wholphin.data.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
@ -43,9 +44,13 @@ data class ItemPlayback(
val audioIndex: Int = TrackIndex.UNSPECIFIED,
val subtitleIndex: Int = TrackIndex.UNSPECIFIED,
) {
@Transient val audioIndexEnabled = audioIndex >= 0
@Transient
@Ignore
val audioIndexEnabled = audioIndex >= 0
@Transient val subtitleIndexEnabled = subtitleIndex >= 0
@Transient
@Ignore
val subtitleIndexEnabled = subtitleIndex >= 0
}
/**

View file

@ -0,0 +1,53 @@
package com.github.damontecres.wholphin.data.model
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import java.util.UUID
@Entity(tableName = "servers")
data class JellyfinServer(
@PrimaryKey val id: UUID,
val name: String?,
val url: String,
)
@Entity(
tableName = "users",
foreignKeys = [
ForeignKey(
entity = JellyfinServer::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("serverId"),
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("id", "serverId", unique = true)],
)
data class JellyfinUser(
@PrimaryKey(autoGenerate = true)
val rowId: Int = 0,
@ColumnInfo(index = true)
val id: UUID,
val name: String?,
@ColumnInfo(index = true)
val serverId: UUID,
val accessToken: String?,
) {
override fun toString(): String =
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken=${accessToken.isNotNullOrBlank()})"
}
data class JellyfinServerUsers(
@Embedded val server: JellyfinServer,
@Relation(
parentColumn = "id",
entityColumn = "serverId",
)
val users: List<JellyfinUser>,
)

View file

@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import com.github.damontecres.wholphin.data.JellyfinUser
enum class NavPinType {
PINNED,

View file

@ -1,6 +1,4 @@
package com.github.damontecres.wholphin.ui.data
import com.github.damontecres.wholphin.data.model.BaseItem
package com.github.damontecres.wholphin.data.model
sealed interface Trailer {
val name: String

View file

@ -3,12 +3,12 @@ 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.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
@Composable
fun LicenseInfo(modifier: Modifier = Modifier) {
val libraries by rememberLibraries()
val libraries by produceLibraries()
LibrariesContainer(libraries, modifier)
}

View file

@ -1,134 +0,0 @@
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.padding
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.Color
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() },
iconColor: Color = Color.Unspecified,
) {
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,
color = iconColor,
fontSize = 16.sp,
fontFamily = FontAwesome,
textAlign = TextAlign.Center,
modifier = Modifier,
)
AnimatedVisibility(isFocused) {
Text(
text = stringResource(title),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}

View file

@ -1,9 +1,15 @@
package com.github.damontecres.wholphin.ui.components
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
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.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
@ -19,105 +25,25 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
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.R
import com.github.damontecres.wholphin.ui.DefaultButtonPadding
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.ifElse
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
*/
@ -212,6 +138,81 @@ fun ExpandablePlayButtons(
}
}
/**
* 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() },
iconColor: Color = Color.Unspecified,
) {
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,
color = iconColor,
fontSize = 16.sp,
fontFamily = FontAwesome,
textAlign = TextAlign.Center,
modifier = Modifier,
)
AnimatedVisibility(isFocused) {
Text(
text = stringResource(title),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
@PreviewTvSpec
@Composable
private fun ExpandablePlayButtonsPreview() {

View file

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

View file

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

View file

@ -39,23 +39,20 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.ChapterRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
@ -69,180 +66,17 @@ import com.github.damontecres.wholphin.ui.components.chooseStream
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.LocalTrailer
import com.github.damontecres.wholphin.ui.data.RemoteTrailer
import com.github.damontecres.wholphin.ui.data.Trailer
import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
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.setValueOnMain
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 kotlinx.coroutines.withContext
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.userLibraryApi
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration
@HiltViewModel
class MovieViewModel
@Inject
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
) : LoadingItemViewModel(api) {
private lateinit var itemId: UUID
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf())
val similar = MutableLiveData<List<BaseItem>>(listOf())
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
override fun init(
itemId: UUID,
potential: BaseItem?,
): Job? {
this.itemId = itemId
return viewModelScope.launch(ExceptionHandler()) {
super.init(itemId, potential)?.join()
item.value?.let { item ->
viewModelScope.launchIO {
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
withContext(Dispatchers.Main) {
chosenStreams.value = result
}
val remoteTrailers =
item.data.remoteTrailers
?.mapNotNull { t ->
t.url?.let { url ->
val name =
t.name
// TODO would be nice to clean up the trailer name
// ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ")
?: "Trailer"
RemoteTrailer(name, url)
}
}.orEmpty()
.sortedBy { it.name }
val localTrailerCount = item.data.localTrailerCount ?: 0
val localTrailers =
if (localTrailerCount > 0) {
api.userLibraryApi.getLocalTrailers(itemId).content.map {
LocalTrailer(BaseItem.from(it, api))
}
} else {
listOf()
}
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
}
}
withContext(Dispatchers.Main) {
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.orEmpty()
chapters.value = Chapter.fromDto(item.data, api)
}
val similar =
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
),
).content.items
.map { BaseItem.from(it, api) }
this@MovieViewModel.similar.setValueOnMain(similar)
}
}
}
fun setWatched(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) {
api.playStateApi.markPlayedItem(itemId)
} else {
api.playStateApi.markUnplayedItem(itemId)
}
fetchAndSetItem(itemId)
}
fun setFavorite(favorite: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (favorite) {
api.userLibraryApi.markFavoriteItem(itemId)
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId)
}
fetchAndSetItem(itemId)
}
fun savePlayVersion(
item: BaseItem,
sourceId: UUID,
) {
viewModelScope.launchIO {
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
}
}
}
fun saveTrackSelection(
item: BaseItem,
itemPlayback: ItemPlayback?,
trackIndex: Int,
type: MediaStreamType,
) {
viewModelScope.launchIO {
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
itemPlayback = itemPlayback,
trackIndex = trackIndex,
type = type,
)
val chosen =
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
}
}
}
}
@Composable
fun MovieDetails(
preferences: UserPreferences,

View file

@ -0,0 +1,177 @@
package com.github.damontecres.wholphin.ui.detail.movie
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
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.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class MovieViewModel
@Inject
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
val itemPlaybackRepository: ItemPlaybackRepository,
) : LoadingItemViewModel(api) {
private lateinit var itemId: UUID
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf())
val similar = MutableLiveData<List<BaseItem>>(listOf())
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
override fun init(
itemId: UUID,
potential: BaseItem?,
): Job? {
this.itemId = itemId
return viewModelScope.launch(ExceptionHandler()) {
super.init(itemId, potential)?.join()
item.value?.let { item ->
viewModelScope.launchIO {
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
withContext(Dispatchers.Main) {
chosenStreams.value = result
}
val remoteTrailers =
item.data.remoteTrailers
?.mapNotNull { t ->
t.url?.let { url ->
val name =
t.name
// TODO would be nice to clean up the trailer name
// ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ")
?: "Trailer"
RemoteTrailer(name, url)
}
}.orEmpty()
.sortedBy { it.name }
val localTrailerCount = item.data.localTrailerCount ?: 0
val localTrailers =
if (localTrailerCount > 0) {
api.userLibraryApi.getLocalTrailers(itemId).content.map {
LocalTrailer(BaseItem.Companion.from(it, api))
}
} else {
listOf()
}
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
}
}
withContext(Dispatchers.Main) {
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.Companion.fromDto(it, api) }
}.orEmpty()
chapters.value = Chapter.Companion.fromDto(item.data, api)
}
val similar =
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
),
).content.items
.map { BaseItem.Companion.from(it, api) }
this@MovieViewModel.similar.setValueOnMain(similar)
}
}
}
fun setWatched(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) {
api.playStateApi.markPlayedItem(itemId)
} else {
api.playStateApi.markUnplayedItem(itemId)
}
fetchAndSetItem(itemId)
}
fun setFavorite(favorite: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (favorite) {
api.userLibraryApi.markFavoriteItem(itemId)
} else {
api.userLibraryApi.unmarkFavoriteItem(itemId)
}
fetchAndSetItem(itemId)
}
fun savePlayVersion(
item: BaseItem,
sourceId: UUID,
) {
viewModelScope.launchIO {
val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId)
val chosen =
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
}
}
}
fun saveTrackSelection(
item: BaseItem,
itemPlayback: ItemPlayback?,
trackIndex: Int,
type: MediaStreamType,
) {
viewModelScope.launchIO {
val result =
itemPlaybackRepository.saveTrackSelection(
item = item,
itemPlayback = itemPlayback,
trackIndex = trackIndex,
type = type,
)
val chosen =
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.detail
package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box

View file

@ -25,9 +25,6 @@ import com.github.damontecres.wholphin.ui.components.chooseStream
import com.github.damontecres.wholphin.ui.components.chooseVersionParams
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.EpisodeList
import com.github.damontecres.wholphin.ui.detail.ItemListAndMapping
import com.github.damontecres.wholphin.ui.detail.SeriesViewModel
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus

View file

@ -50,7 +50,6 @@ 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

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.ui.detail
package com.github.damontecres.wholphin.ui.detail.series
import android.content.Context
import android.widget.Toast
@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination

View file

@ -7,8 +7,8 @@ import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDe
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.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences
import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber

View file

@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv
import com.github.damontecres.wholphin.ui.detail.DebugPage
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
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.SeriesDetails
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

View file

@ -59,9 +59,9 @@ 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.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.ifElse

View file

@ -864,53 +864,3 @@ private fun applyTrackSelections(
}
}
}
private fun determineSubtitleIndex(
subtitleStreams: List<SubtitleStream>,
subtitleMode: SubtitlePlaybackMode,
audioLanguage: String?,
subtitleLanguage: String?,
playbackConfig: ItemPlayback,
): Int? {
if (playbackConfig.subtitleIndex == TrackIndex.DISABLED) {
return null
} else if (playbackConfig.subtitleIndexEnabled) {
return playbackConfig.subtitleIndex
} else {
return 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
}
}
}

View file

@ -23,7 +23,7 @@ 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.data.model.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

View file

@ -3,10 +3,10 @@ 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.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager

View file

@ -21,7 +21,7 @@ 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.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogPopup

View file

@ -1,11 +0,0 @@
package com.github.damontecres.wholphin.util
class LazyList<Source, Transform>(
private val source: List<Source>,
private val transform: (Source) -> Transform,
) : AbstractList<Transform>() {
override fun get(index: Int): Transform = transform.invoke(source[index])
override val size: Int
get() = source.size
}