WIP, basic MVP

This commit is contained in:
Damontecres 2026-01-23 11:32:55 -05:00
parent fc0de2144d
commit 6d53869180
No known key found for this signature in database
11 changed files with 627 additions and 3 deletions

View file

@ -265,7 +265,8 @@ class MainActivity : AppCompatActivity() {
server = current.server, server = current.server,
startDestination = startDestination =
requestedDestination requestedDestination
?: Destination.Home(), // TODO Undo
?: Destination.HomeSettings,
navigationManager = navigationManager, navigationManager = navigationManager,
preferences = preferences, preferences = preferences,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.AuthenticationResult
@ -102,6 +103,27 @@ class ServerRepository
currentUserId = updatedUser.id.toServerString() currentUserId = updatedUser.id.toServerString()
}.build() }.build()
} }
val settings =
apiClient.displayPreferencesApi
.getDisplayPreferences(
displayPreferencesId = "settings",
userId = user.id,
client = "wholphin",
).content
val newSettings =
settings.copy(
customPrefs =
settings.customPrefs.toMutableMap().apply {
put("test_key", "test_value")
},
)
apiClient.displayPreferencesApi.updateDisplayPreferences(
displayPreferencesId = "settings",
userId = user.id,
client = "wholphin",
data = newSettings,
)
Timber.v("settings=$settings")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_current.value = CurrentUser(updatedServer, updatedUser) _current.value = CurrentUser(updatedServer, updatedUser)
_currentUserDto.value = userDto _currentUserDto.value = userDto

View file

@ -0,0 +1,58 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
sealed class HomeRowConfig {
abstract val viewOptions: HomeRowViewOptions
@Serializable
data class ContinueWatching(
val combined: Boolean,
override val viewOptions: HomeRowViewOptions,
) : HomeRowConfig()
@Serializable
data class RecentlyAdded(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions,
) : HomeRowConfig()
@Serializable
data class RecentlyReleased(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions,
) : HomeRowConfig()
@Serializable
data class Genres(
val genreId: UUID,
val parentId: UUID,
override val viewOptions: HomeRowViewOptions,
) : HomeRowConfig()
}
data class HomeRowConfigDisplay(
val title: String,
val config: HomeRowConfig,
)
@Serializable
data class HomeRowViewOptions(
val heightDp: Int = Cards.HEIGHT_2X3_DP,
val spacing: Int = 16,
val contentScale: PrefContentScale = PrefContentScale.FIT,
val aspectRatio: AspectRatio = AspectRatio.TALL,
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
val showTitles: Boolean = true,
val useSeries: Boolean = true,
)

View file

@ -73,7 +73,8 @@ val SlimItemFields =
) )
object Cards { object Cards {
val height2x3 = 172.dp const val HEIGHT_2X3_DP = 172
val height2x3 = HEIGHT_2X3_DP.dp
val heightEpisode = height2x3 * .75f val heightEpisode = height2x3 * .75f
val playedPercentHeight = 6.dp val playedPercentHeight = 6.dp
val serverUserCircle = height2x3 * .75f val serverUserCircle = height2x3 * .75f

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -192,6 +193,7 @@ fun HomePageContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onFocusPosition: ((RowColumn) -> Unit)? = null, onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null, loadingState: LoadingState? = null,
listState: LazyListState = rememberLazyListState(),
) { ) {
var position by rememberPosition() var position by rememberPosition()
val focusedItem = val focusedItem =
@ -199,7 +201,6 @@ fun HomePageContent(
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
} }
val listState = rememberLazyListState()
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
var firstFocused by remember { mutableStateOf(false) } var firstFocused by remember { mutableStateOf(false) }
LaunchedEffect(homeRows) { LaunchedEffect(homeRows) {

View file

@ -0,0 +1,155 @@
package com.github.damontecres.wholphin.ui.main.settings
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
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.Delete
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.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
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.data.model.HomeRowConfigDisplay
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.ifElse
enum class MoveDirection {
UP,
DOWN,
}
@Composable
fun HomePageRowList(
state: HomePageSettingsState,
onClickAdd: () -> Unit,
onClickMove: (MoveDirection, Int) -> Unit,
onClickDelete: (Int) -> Unit,
modifier: Modifier,
firstFocus: FocusRequester = remember { FocusRequester() },
) {
Column(modifier = modifier) {
LazyColumn(
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.fillMaxHeight()
.focusRestorer(firstFocus),
) {
itemsIndexed(state.rows) { index, row ->
HomeRowConfigContent(
config = row,
moveUpAllowed = index > 0,
moveDownAllowed = index != state.rows.lastIndex,
onClickMove = { onClickMove.invoke(it, index) },
onClickDelete = { onClickDelete.invoke(index) },
modifier =
Modifier
.fillMaxWidth()
.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
)
}
item {
Button(
onClick = onClickAdd,
modifier = Modifier.ifElse(state.rows.isEmpty(), Modifier.focusRequester(firstFocus)),
) {
Text(
text = stringResource(R.string.add_row),
)
}
}
}
}
}
@Composable
fun HomeRowConfigContent(
config: HomeRowConfigDisplay,
moveUpAllowed: Boolean,
moveDownAllowed: Boolean,
onClickMove: (MoveDirection) -> Unit,
onClickDelete: () -> Unit,
modifier: Modifier,
) {
Box(
modifier = modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 40.dp, max = 88.dp)
.background(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
shape = RoundedCornerShape(8.dp),
).padding(8.dp),
) {
Text(
text = config.title,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.wrapContentWidth(),
) {
Button(
onClick = { onClickMove.invoke(MoveDirection.UP) },
enabled = moveUpAllowed,
) {
Text(
text = stringResource(R.string.fa_caret_up),
fontFamily = FontAwesome,
)
}
Button(
onClick = { onClickMove.invoke(MoveDirection.DOWN) },
enabled = moveDownAllowed,
) {
Text(
text = stringResource(R.string.fa_caret_down),
fontFamily = FontAwesome,
)
}
Button(
onClick = onClickDelete,
enabled = true,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "delete",
modifier = Modifier,
)
}
}
}
}
}

View file

@ -0,0 +1,359 @@
package com.github.damontecres.wholphin.ui.main.settings
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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 androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowConfigDisplay
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.LatestData
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class HomePageSettingsViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
private val navDrawerItemRepository: NavDrawerItemRepository,
private val backdropService: BackdropService,
private val latestNextUpService: LatestNextUpService,
) : ViewModel() {
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
val state: StateFlow<HomePageSettingsState> = _state
init {
viewModelScope.launchIO {
val navDrawerItems =
navDrawerItemRepository
.getNavDrawerItems()
val libraries =
navDrawerItems
.filter { it is ServerNavDrawerItem }
.map {
it as ServerNavDrawerItem
Library(it.itemId, it.name, it.type)
}
_state.update { it.copy(libraries = libraries) }
// TODO get config
// Or create default
val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
val includedIds =
navDrawerItemRepository
.getFilteredNavDrawerItems(navDrawerItems)
.filter { it is ServerNavDrawerItem }
.map {
val id = (it as ServerNavDrawerItem).itemId
val name = libraries.firstOrNull { it.itemId == id }?.name
val title =
name?.let { context.getString(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added)
HomeRowConfigDisplay(
title,
HomeRowConfig.RecentlyAdded(id, HomeRowViewOptions()),
)
}
val rowConfig =
listOf(
HomeRowConfigDisplay(
context.getString(R.string.continue_watching),
HomeRowConfig.ContinueWatching(
prefs.combineContinueNext,
HomeRowViewOptions(),
),
),
) + includedIds
_state.update {
it.copy(rows = rowConfig)
}
fetchRowData()
}
}
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
private suspend fun fetchRowData() {
val limit = 6
val rows =
serverRepository.currentUserDto.value?.let { userDto ->
val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
state.value.rows
.map { it.config }
.map { row ->
// TODO parallelize
when (row) {
is HomeRowConfig.ContinueWatching -> {
val resume = latestNextUpService.getResume(userDto.id, limit, true)
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
)
val watching =
buildList {
if (row.combined) {
val items =
latestNextUpService.buildCombined(resume, nextUp)
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.continue_watching),
items = items,
),
)
} else {
if (resume.isNotEmpty()) {
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.continue_watching),
items = resume,
),
)
}
if (nextUp.isNotEmpty()) {
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.next_up),
items = nextUp,
),
)
}
}
}
watching
}
is HomeRowConfig.Genres -> {
val request =
GetGenresRequest(
parentId = row.parentId,
userId = userDto.id,
limit = limit,
)
val items =
GetGenresRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, false) }
val name =
_state.value.libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val title =
name?.let { context.getString(R.string.genres_in, it) }
?: context.getString(R.string.genres)
listOf(HomeRowLoadingState.Success(title, items))
}
is HomeRowConfig.RecentlyAdded -> {
val name =
_state.value.libraries
.firstOrNull { it.itemId == row.parentId }
?.name
val title =
name?.let { context.getString(R.string.recently_added_in, it) }
?: context.getString(R.string.recently_added)
val request =
GetLatestMediaRequest(
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = row.parentId,
groupItems = true,
limit = limit,
isPlayed = null, // Server will handle user's preference
)
latestNextUpService.loadLatest(listOf(LatestData(title, request)))
}
is HomeRowConfig.RecentlyReleased -> {
TODO()
}
}
}.flatten()
}
rows?.let { rows ->
_state.update {
it.copy(loading = LoadingState.Success, rowData = rows)
}
}
}
private fun <T> List<T>.move(
direction: MoveDirection,
index: Int,
): List<T> =
toMutableList().apply {
if (direction == MoveDirection.DOWN) {
val down = this[index]
val up = this[index + 1]
set(index, up)
set(index + 1, down)
} else {
val up = this[index]
val down = this[index - 1]
set(index - 1, up)
set(index, down)
}
}
fun moveRow(
direction: MoveDirection,
index: Int,
) {
_state.update {
val rows = it.rows.move(direction, index)
// TODO would be more efficient to move rowData, but uncombined continue watching is two rows
// val rowData = it.rowData.move(direction, index)
it.copy(
loading = LoadingState.Loading,
rows = rows,
)
}
viewModelScope.launchIO { fetchRowData() }
}
fun deleteRow(index: Int) {
_state.update {
val rows = it.rows.toMutableList().apply { removeAt(index) }
val rowData = it.rowData.toMutableList().apply { removeAt(index) }
it.copy(
rows = rows,
rowData = rowData,
)
}
}
}
data class HomePageSettingsState(
val loading: LoadingState,
val rows: List<HomeRowConfigDisplay>,
val rowData: List<HomeRowLoadingState>,
val libraries: List<Library>,
) {
companion object {
val EMPTY =
HomePageSettingsState(
LoadingState.Pending,
listOf(),
listOf(),
listOf(),
)
}
}
data class Library(
val itemId: UUID,
val name: String,
val collectionType: CollectionType,
)
val settingsWidth = 300.dp
@Composable
fun HomePageSettings(
modifier: Modifier,
viewModel: HomePageSettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val listState = rememberLazyListState()
var destination by remember { mutableStateOf<HomePageSettingsDestination>(HomePageSettingsDestination.RowList) }
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
HomePageContent(
loadingState = state.loading,
homeRows = state.rowData,
onClickItem = { _, _ -> },
onLongClickItem = { _, _ -> },
onClickPlay = { _, _ -> },
showClock = false,
onUpdateBackdrop = viewModel::updateBackdrop,
listState = listState,
modifier = Modifier.fillMaxHeight().weight(1f),
)
Box(
modifier =
Modifier
.width(settingsWidth)
.fillMaxHeight()
.background(color = MaterialTheme.colorScheme.surface),
) {
when (destination) {
HomePageSettingsDestination.RowList -> {
HomePageRowList(
state = state,
onClickAdd = { destination = HomePageSettingsDestination.ChooseLibrary },
onClickMove = viewModel::moveRow,
onClickDelete = viewModel::deleteRow,
modifier = Modifier.fillMaxSize(),
)
}
is HomePageSettingsDestination.ChooseLibrary -> {
TODO()
}
is HomePageSettingsDestination.ChooseRow -> {
TODO()
}
is HomePageSettingsDestination.RowSettings -> {
TODO()
}
}
}
}
}

View file

@ -0,0 +1,17 @@
package com.github.damontecres.wholphin.ui.main.settings
import com.github.damontecres.wholphin.data.model.HomeRowConfigDisplay
sealed interface HomePageSettingsDestination {
data object RowList : HomePageSettingsDestination
data object ChooseLibrary : HomePageSettingsDestination
data class ChooseRow(
val library: Library,
) : HomePageSettingsDestination
data class RowSettings(
val row: HomeRowConfigDisplay,
) : HomePageSettingsDestination
}

View file

@ -33,6 +33,9 @@ sealed class Destination(
val id: Long = 0L, val id: Long = 0L,
) : Destination() ) : Destination()
@Serializable
data object HomeSettings : Destination(true)
@Serializable @Serializable
data class Settings( data class Settings(
val screen: PreferenceScreenOption, val screen: PreferenceScreenOption,

View file

@ -32,6 +32,7 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.discover.DiscoverPage
import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.main.SearchPage
import com.github.damontecres.wholphin.ui.main.settings.HomePageSettings
import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
@ -60,6 +61,10 @@ fun DestinationContent(
) )
} }
is Destination.HomeSettings -> {
HomePageSettings(modifier)
}
is Destination.PlaybackList, is Destination.PlaybackList,
is Destination.Playback, is Destination.Playback,
-> { -> {

View file

@ -462,6 +462,8 @@
<string name="request_4k">Request in 4K</string> <string name="request_4k">Request in 4K</string>
<string name="software_decoding_av1">AV1 software decoding</string> <string name="software_decoding_av1">AV1 software decoding</string>
<string name="upgrade_mpv_toast">MPV is now the default player except for HDR.\nYou can change this in settings.</string> <string name="upgrade_mpv_toast">MPV is now the default player except for HDR.\nYou can change this in settings.</string>
<string name="add_row">Add row</string>
<string name="genres_in">Genres in %1$s</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>