Basic save/load locally

This commit is contained in:
Damontecres 2026-01-24 16:05:33 -05:00
parent 84dc56289c
commit ee8a4ac0ac
No known key found for this signature in database
8 changed files with 392 additions and 123 deletions

View file

@ -99,7 +99,7 @@ sealed class HomeRowConfig {
val parentId: UUID,
override val viewOptions: HomeRowViewOptions =
HomeRowViewOptions(
heightDp = (Cards.HEIGHT_2X3_DP * .75f).toInt(),
heightDp = (Cards.HEIGHT_2X3_DP * .75f).toInt().let { it - it % 4 },
aspectRatio = AspectRatio.WIDE,
),
) : HomeRowConfig() {
@ -128,7 +128,7 @@ sealed class HomeRowConfig {
@SerialName("GetItems")
data class GetItems(
override val id: Int,
val name: String?,
val name: String,
val getItems: GetItemsRequest,
override val viewOptions: HomeRowViewOptions,
) : HomeRowConfig() {
@ -142,8 +142,8 @@ data class HomeRowConfigDisplay(
)
@Serializable
@SerialName("HomePageConfiguration")
data class HomePageConfiguration(
@SerialName("HomePageSettings")
data class HomePageSettings(
val version: Int = 1,
val rows: List<HomeRowConfig>,
)

View file

@ -0,0 +1,102 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.ui.toServerString
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.model.UUID
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HomeSettingsService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
) {
val jsonParser =
Json {
isLenient = true
ignoreUnknownKeys = true
}
suspend fun saveToServer(
settings: HomePageSettings,
displayPreferencesId: String = DISPLAY_PREF_ID,
) {
serverRepository.currentUser.value?.let { user ->
val current = getDisplayPreferences(user.id, DISPLAY_PREF_ID)
val customPrefs =
current.customPrefs.toMutableMap().apply {
put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings))
}
api.displayPreferencesApi.updateDisplayPreferences(
displayPreferencesId = displayPreferencesId,
userId = user.id,
client = context.getString(R.string.app_name),
data = current.copy(customPrefs = customPrefs),
)
}
}
suspend fun loadFromServer(displayPreferencesId: String = DISPLAY_PREF_ID): HomePageSettings? =
serverRepository.currentUser.value?.let { user ->
val current = getDisplayPreferences(user.id, displayPreferencesId)
current.customPrefs[DISPLAY_PREF_ID]?.let {
Json.decodeFromString<HomePageSettings>(it)
}
}
private suspend fun getDisplayPreferences(
userId: UUID,
displayPreferencesId: String,
) = api.displayPreferencesApi
.getDisplayPreferences(
userId = userId,
displayPreferencesId = displayPreferencesId,
client = context.getString(R.string.app_name),
).content
private fun filename(userId: UUID) = "${CUSTOM_PREF_ID}_${userId.toServerString()}.json"
@OptIn(ExperimentalSerializationApi::class)
suspend fun saveToLocal(settings: HomePageSettings) {
serverRepository.currentUser.value?.let { user ->
val dir = File(context.filesDir, CUSTOM_PREF_ID)
dir.mkdirs()
File(dir, filename(user.id)).outputStream().use {
jsonParser.encodeToStream(settings, it)
}
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun loadFromLocal(): HomePageSettings? =
serverRepository.currentUser.value?.let { user ->
val dir = File(context.filesDir, CUSTOM_PREF_ID)
val file = File(dir, filename(user.id))
if (file.exists()) {
file.inputStream().use {
jsonParser.decodeFromStream<HomePageSettings>(it)
}
} else {
null
}
}
companion object {
const val DISPLAY_PREF_ID = "default"
const val CUSTOM_PREF_ID = "home_settings"
}
}

View file

@ -195,6 +195,7 @@ fun HomePageContent(
onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null,
listState: LazyListState = rememberLazyListState(),
takeFocus: Boolean = true,
) {
var position by rememberPosition()
val focusedItem =
@ -204,22 +205,24 @@ fun HomePageContent(
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
var firstFocused by remember { mutableStateOf(false) }
LaunchedEffect(homeRows) {
if (!firstFocused) {
if (position.row >= 0) {
rowFocusRequesters[position.row].tryRequestFocus()
firstFocused = true
} else {
// Waiting for the first home row to load, then focus on it
homeRows
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
.takeIf { it >= 0 }
?.let {
rowFocusRequesters[it].tryRequestFocus()
firstFocused = true
delay(50)
listState.scrollToItem(it)
}
if (takeFocus) {
LaunchedEffect(homeRows) {
if (!firstFocused) {
if (position.row >= 0) {
rowFocusRequesters[position.row].tryRequestFocus()
firstFocused = true
} else {
// Waiting for the first home row to load, then focus on it
homeRows
.indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
.takeIf { it >= 0 }
?.let {
rowFocusRequesters[it].tryRequestFocus()
firstFocused = true
delay(50)
listState.scrollToItem(it)
}
}
}
}
}
@ -421,19 +424,17 @@ fun HomePageHeader(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
item?.let {
val isEpisode = item.type == BaseItemKind.EPISODE
val dto = item.data
HomePageHeader(
title = item.title,
subtitle = if (isEpisode) dto.name else null,
overview = dto.overview,
overviewTwoLines = isEpisode,
quickDetails = item.ui.quickDetails,
timeRemaining = item.timeRemainingOrRuntime,
modifier = modifier,
)
}
val isEpisode = item?.type == BaseItemKind.EPISODE
val dto = item?.data
HomePageHeader(
title = item?.title,
subtitle = if (isEpisode) dto?.name else null,
overview = dto?.overview,
overviewTwoLines = isEpisode,
quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
timeRemaining = item?.timeRemainingOrRuntime,
modifier = modifier,
)
}
@Composable

View file

@ -4,17 +4,22 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun HomeRowSettings(
@ -25,6 +30,8 @@ fun HomeRowSettings(
modifier: Modifier = Modifier,
defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) {
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
Column(modifier = modifier) {
Text(
text = title,
@ -32,7 +39,7 @@ fun HomeRowSettings(
color = MaterialTheme.colorScheme.onSurface,
)
LazyColumn {
items(HomeRowViewOptions.OPTIONS) { pref ->
itemsIndexed(HomeRowViewOptions.OPTIONS) { index, pref ->
pref as AppPreference<HomeRowViewOptions, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(viewOptions)
@ -51,7 +58,13 @@ fun HomeRowSettings(
onApplyApplyAll.invoke()
}
},
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)),
modifier =
Modifier
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(
5.dp,
),
).ifElse(index == 0, Modifier.focusRequester(firstFocus)),
)
}
}

View file

@ -1,9 +1,11 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.navigation3.runtime.NavKey
/**
* Tracking the pages for selecting and configuring rows
*/
sealed interface HomeSettingsDestination {
sealed interface HomeSettingsDestination : NavKey {
data object RowList : HomeSettingsDestination
data object ChooseLibrary : HomeSettingsDestination

View file

@ -15,12 +15,16 @@ import androidx.compose.runtime.collectAsState
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.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import timber.log.Timber
val settingsWidth = 300.dp
@ -29,10 +33,12 @@ fun HomeSettingsPage(
modifier: Modifier,
viewModel: HomeSettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
var destination by remember { mutableStateOf<HomeSettingsDestination>(HomeSettingsDestination.RowList) }
val state by viewModel.state.collectAsState()
BackHandler(destination is HomeSettingsDestination.ChooseRowType) {
destination = HomeSettingsDestination.ChooseLibrary
}
@ -62,10 +68,15 @@ fun HomeSettingsPage(
HomeSettingsRowList(
state = state,
onClickAdd = { destination = HomeSettingsDestination.ChooseLibrary },
onClickSaveLocal = { viewModel.saveToLocal() },
onClickMove = viewModel::moveRow,
onClickDelete = viewModel::deleteRow,
onClick = { index, row ->
destination = HomeSettingsDestination.RowSettings(row.config.id)
scope.launch(ExceptionHandler()) {
Timber.v("Scroll to $index")
listState.scrollToItem(index)
}
},
modifier = destModifier,
)
@ -121,6 +132,7 @@ fun HomeSettingsPage(
showClock = false,
onUpdateBackdrop = viewModel::updateBackdrop,
listState = listState,
takeFocus = false,
modifier =
Modifier
.fillMaxHeight()

View file

@ -13,8 +13,12 @@ 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.Add
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.HorizontalDivider
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
@ -33,7 +37,7 @@ 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
import com.github.damontecres.wholphin.ui.tryRequestFocus
enum class MoveDirection {
UP,
@ -44,12 +48,14 @@ enum class MoveDirection {
fun HomeSettingsRowList(
state: HomePageSettingsState,
onClick: (Int, HomeRowConfigDisplay) -> Unit,
onClickSaveLocal: () -> Unit,
onClickAdd: () -> Unit,
onClickMove: (MoveDirection, Int) -> Unit,
onClickDelete: (Int) -> Unit,
modifier: Modifier,
firstFocus: FocusRequester = remember { FocusRequester() },
) {
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
Column(modifier = modifier) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -58,6 +64,45 @@ fun HomeSettingsRowList(
.fillMaxHeight()
.focusRestorer(firstFocus),
) {
item {
ListItem(
selected = false,
headlineContent = {
Text(
text = stringResource(R.string.add_row),
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
)
},
onClick = onClickAdd,
modifier = Modifier.focusRequester(firstFocus),
)
}
item {
ListItem(
selected = false,
headlineContent = {
Text(
text = stringResource(R.string.save),
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.Create,
contentDescription = null,
)
},
onClick = onClickSaveLocal,
modifier = Modifier,
)
}
item {
HorizontalDivider()
}
itemsIndexed(state.rows, key = { _, row -> row.config.id }) { index, row ->
HomeRowConfigContent(
config = row,
@ -69,20 +114,9 @@ fun HomeSettingsRowList(
onClick = { onClick.invoke(index, row) },
modifier =
Modifier
.fillMaxWidth()
.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
.fillMaxWidth(),
)
}
item {
Button(
onClick = onClickAdd,
modifier = Modifier.ifElse(state.rows.isEmpty(), Modifier.focusRequester(firstFocus)),
) {
Text(
text = stringResource(R.string.add_row),
)
}
}
}
}
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.main.settings
import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -8,11 +9,13 @@ 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.HomePageSettings
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.preferences.HomePagePreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.UserPreferencesService
@ -21,6 +24,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
@ -40,6 +44,7 @@ import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@ -49,6 +54,7 @@ class HomeSettingsViewModel
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val homeSettingsService: HomeSettingsService,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
private val navDrawerItemRepository: NavDrawerItemRepository,
@ -73,91 +79,176 @@ class HomeSettingsViewModel
}
_state.update { it.copy(libraries = libraries) }
// TODO get config
val localSettings =
try {
homeSettingsService.loadFromLocal()
} catch (ex: Exception) {
Timber.e(ex)
showToast(context, "Error loading settings: ${ex.localizedMessage}")
null
}
if (localSettings != null) {
val displays = localSettings.rows.map { convert(it) }
_state.update {
it.copy(rows = displays)
}
} else {
// Or create default
val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
val includedIds =
navDrawerItemRepository
.getFilteredNavDrawerItems(navDrawerItems)
.filter { it is ServerNavDrawerItem }
.mapIndexed { index, it ->
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(
index,
id,
HomeRowViewOptions(),
// Or create default
val prefs =
userPreferencesService.getCurrent().appPreferences.homePagePreferences
val includedIds =
navDrawerItemRepository
.getFilteredNavDrawerItems(navDrawerItems)
.filter { it is ServerNavDrawerItem }
.mapIndexed { index, it ->
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(
index,
id,
HomeRowViewOptions(),
),
)
}
val continueWatchingRows =
if (prefs.combineContinueNext) {
listOf(
HomeRowConfigDisplay(
context.getString(R.string.combine_continue_next),
HomeRowConfig.ContinueWatchingCombined(
includedIds.size + 1,
HomeRowViewOptions(),
),
),
)
} else {
listOf(
HomeRowConfigDisplay(
context.getString(R.string.continue_watching),
HomeRowConfig.ContinueWatching(
includedIds.size + 1,
HomeRowViewOptions(),
),
),
HomeRowConfigDisplay(
context.getString(R.string.next_up),
HomeRowConfig.NextUp(
includedIds.size + 2,
HomeRowViewOptions(),
),
),
)
}
val continueWatchingRows =
if (prefs.combineContinueNext) {
listOf(
HomeRowConfigDisplay(
context.getString(R.string.combine_continue_next),
HomeRowConfig.ContinueWatchingCombined(
includedIds.size + 1,
HomeRowViewOptions(),
val rowConfig =
continueWatchingRows + includedIds +
// TODO remove after testing
listOf(
HomeRowConfigDisplay(
"Collection",
HomeRowConfig.ByParent(
id = 100,
parentId = "34ab6fd1f51c41bb014981f2e334f465".toUUID(),
recursive = true,
viewOptions = HomeRowViewOptions(),
),
),
),
)
} else {
listOf(
HomeRowConfigDisplay(
context.getString(R.string.continue_watching),
HomeRowConfig.ContinueWatching(
includedIds.size + 1,
HomeRowViewOptions(),
HomeRowConfigDisplay(
"Playlist",
HomeRowConfig.ByParent(
id = 101,
parentId = "f94be36e9836127a0bccfc7843b19e5b".toUUID(),
recursive = true,
viewOptions = HomeRowViewOptions(),
),
),
),
HomeRowConfigDisplay(
context.getString(R.string.next_up),
HomeRowConfig.NextUp(
includedIds.size + 2,
HomeRowViewOptions(),
),
),
)
)
_state.update {
it.copy(rows = rowConfig)
}
val rowConfig =
continueWatchingRows + includedIds +
// TODO remove after testing
listOf(
HomeRowConfigDisplay(
"Collection",
HomeRowConfig.ByParent(
id = 100,
parentId = "34ab6fd1f51c41bb014981f2e334f465".toUUID(),
recursive = true,
viewOptions = HomeRowViewOptions(),
),
),
HomeRowConfigDisplay(
"Playlist",
HomeRowConfig.ByParent(
id = 101,
parentId = "f94be36e9836127a0bccfc7843b19e5b".toUUID(),
recursive = true,
viewOptions = HomeRowViewOptions(),
),
),
)
_state.update {
it.copy(rows = rowConfig)
}
// val json = Json.encodeToString(rowConfig)
// Timber.v("json=$json")
fetchRowData()
}
}
private suspend fun convert(config: HomeRowConfig): HomeRowConfigDisplay =
when (config) {
is HomeRowConfig.ByParent -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
name,
config,
)
}
is HomeRowConfig.ContinueWatching -> {
HomeRowConfigDisplay(
context.getString(R.string.continue_watching),
config,
)
}
is HomeRowConfig.ContinueWatchingCombined -> {
HomeRowConfigDisplay(
context.getString(R.string.combine_continue_next),
config,
)
}
is HomeRowConfig.Genres -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
context.getString(R.string.genres_in, name),
config,
)
}
is HomeRowConfig.GetItems -> {
HomeRowConfigDisplay(config.name, config)
}
is HomeRowConfig.NextUp -> {
HomeRowConfigDisplay(
context.getString(R.string.next_up),
config,
)
}
is HomeRowConfig.RecentlyAdded -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
context.getString(R.string.recently_added_in, name),
config,
)
}
is HomeRowConfig.RecentlyReleased -> {
val name =
api.userLibraryApi
.getItem(itemId = config.parentId)
.content.name ?: ""
HomeRowConfigDisplay(
context.getString(R.string.recently_released_in, name),
config,
)
}
}
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
@ -615,6 +706,20 @@ class HomeSettingsViewModel
)
}
}
fun saveToLocal() {
viewModelScope.launchIO {
val rows = state.value.rows.map { it.config }
val settings = HomePageSettings(rows = rows)
try {
homeSettingsService.saveToLocal(settings)
showToast(context, context.getString(R.string.save), Toast.LENGTH_SHORT)
} catch (ex: Exception) {
Timber.e(ex)
showToast(context, "Error saving: ${ex.localizedMessage}")
}
}
}
}
data class HomePageSettingsState(