diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PageConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PageConfig.kt new file mode 100644 index 000000000..359d08e94 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PageConfig.kt @@ -0,0 +1,39 @@ +package com.github.damontecres.wholphin.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class PagePosition { + @SerialName("AfterHome") + AFTER_HOME, + + @SerialName("AfterFavorites") + AFTER_FAVORITES, + + @SerialName("AfterDiscover") + AFTER_DISCOVER, + + @SerialName("AfterLibraries") + AFTER_LIBRARIES, + + @SerialName("End") + END, +} + +@Serializable +data class PageSummary( + val id: String, + val title: String, + val icon: String? = null, + val position: PagePosition = PagePosition.AFTER_HOME, +) + +@Serializable +data class PageConfig( + val id: String, + val title: String, + val icon: String? = null, + val position: PagePosition = PagePosition.AFTER_HOME, + val rows: List = emptyList(), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/CustomPageRowsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/CustomPageRowsCache.kt new file mode 100644 index 000000000..4e24134a2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/CustomPageRowsCache.kt @@ -0,0 +1,50 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.PageConfig +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import org.jellyfin.sdk.model.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Process-lifetime in-memory cache for custom-page rows. + * + * The Wholphin home page benefits from a long-lived state because its [Destination] sits at index 0 + * of the back stack and the HomeViewModel is never recreated. Custom pages get a fresh ViewModel on + * every navigation (the back stack pops and re-pushes the entry), so without this cache every visit + * re-fetches all rows. Keyed by userId to avoid leaking content across user switches. + */ +@Singleton +class CustomPageRowsCache + @Inject + constructor() { + private val cache = ConcurrentHashMap() + + fun get( + userId: UUID, + pageId: String, + ): CachedPageData? = cache[key(userId, pageId)] + + fun put( + userId: UUID, + pageId: String, + data: CachedPageData, + ) { + cache[key(userId, pageId)] = data + } + + fun clear() { + cache.clear() + } + + private fun key( + userId: UUID, + pageId: String, + ) = "$userId:$pageId" + } + +data class CachedPageData( + val page: PageConfig, + val rows: List, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index 68486430b..67a5f1371 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -6,9 +6,11 @@ import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.data.model.PagePosition import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.nav.CustomPageNavDrawerItem import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem @@ -52,6 +54,8 @@ class NavDrawerService private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, private val musicService: MusicService, + private val serverPluginApi: ServerPluginApi, + private val customPageRowsCache: CustomPageRowsCache, ) { private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) val state: StateFlow = _state @@ -70,6 +74,7 @@ class NavDrawerService moreItems = emptyList(), ) } + customPageRowsCache.clear() if (user != null && userDto != null && user.id == userDto.id) { updateNavDrawer(user, userDto) } @@ -227,13 +232,92 @@ class NavDrawerService } } + val customPagesByPosition = + fetchCustomPagesByPosition() + + val itemsWithPages = + insertCustomPages(items, customPagesByPosition) + val moreItemsWithPages = + moreItems + + customPagesByPosition[PagePosition.END].orEmpty() + _state.update { it.copy( - items = items, - moreItems = moreItems, + items = itemsWithPages, + moreItems = moreItemsWithPages, ) } } + + private suspend fun fetchCustomPagesByPosition(): Map> { + val pages = + try { + serverPluginApi.fetchPages() + } catch (ex: Exception) { + Timber.w(ex, "Failed to fetch custom pages from plugin") + return emptyMap() + } + return pages + .map { CustomPageNavDrawerItem(it.id, it.title, it.icon) to it.position } + .groupBy({ it.second }, { it.first }) + } + + /** + * Inserts custom pages into the items list at their configured positions: + * - AfterHome → at the very start (Home itself is hardcoded in the composable, before [items]) + * - AfterFavorites → directly after the Favorites entry + * - AfterDiscover → directly after the Discover entry + * - AfterLibraries → after the last library entry + * + * If an anchor isn't present (e.g. user moved Discover to moreItems), the pages anchored on + * it fall through to the end of the items list. + */ + private fun insertCustomPages( + items: List, + byPosition: Map>, + ): List { + if (byPosition.isEmpty()) return items + + val result = mutableListOf() + result += byPosition[PagePosition.AFTER_HOME].orEmpty() + + var lastLibraryIndex = -1 + var sawFavorites = false + var sawDiscover = false + items.forEach { item -> + result += item + when (item) { + NavDrawerItem.Favorites -> { + result += byPosition[PagePosition.AFTER_FAVORITES].orEmpty() + sawFavorites = true + } + + NavDrawerItem.Discover -> { + result += byPosition[PagePosition.AFTER_DISCOVER].orEmpty() + sawDiscover = true + } + + is ServerNavDrawerItem -> { + lastLibraryIndex = result.size - 1 + } + + else -> {} + } + } + + val afterLibraries = byPosition[PagePosition.AFTER_LIBRARIES].orEmpty() + if (afterLibraries.isNotEmpty()) { + if (lastLibraryIndex >= 0) { + result.addAll(lastLibraryIndex + 1, afterLibraries) + } else { + result += afterLibraries + } + } + if (!sawFavorites) result += byPosition[PagePosition.AFTER_FAVORITES].orEmpty() + if (!sawDiscover) result += byPosition[PagePosition.AFTER_DISCOVER].orEmpty() + + return result + } } data class NavDrawerItemState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt index 794e69816..224adc661 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt @@ -1,6 +1,8 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.model.HomePageSettings +import com.github.damontecres.wholphin.data.model.PageConfig +import com.github.damontecres.wholphin.data.model.PageSummary import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -25,11 +27,12 @@ class ServerPluginApi private val json = Json { - ignoreUnknownKeys = false + ignoreUnknownKeys = true } companion object { private const val HOME_CONFIG_PATH = "homesettings" + private const val PAGES_PATH = "pages" } suspend fun public(): Boolean { @@ -63,4 +66,44 @@ class ServerPluginApi } } } + + @OptIn(ExperimentalSerializationApi::class) + suspend fun fetchPages(): List { + val url = createUrl(PAGES_PATH) ?: return emptyList() + val request = + Request + .Builder() + .url(url) + .get() + .build() + return okHttpClient.newCall(request).execute().use { res -> + if (res.isSuccessful) { + json.decodeFromStream>(res.body.byteStream()) + } else { + Timber.w("fetchPages returned HTTP %d", res.code) + emptyList() + } + } + } + + @OptIn(ExperimentalSerializationApi::class) + suspend fun fetchPage(id: String): PageConfig? { + val url = createUrl("$PAGES_PATH/$id") ?: return null + val request = + Request + .Builder() + .url(url) + .get() + .build() + return okHttpClient.newCall(request).execute().use { res -> + if (res.isSuccessful) { + json.decodeFromStream(res.body.byteStream()) + } else if (res.code == 404) { + Timber.w("fetchPage(%s) returned 404", id) + null + } else { + throw ApiClientException(res.code.toString() + " " + res.body.string()) + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/CustomPagePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/CustomPagePage.kt new file mode 100644 index 000000000..9390f1e13 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/CustomPagePage.kt @@ -0,0 +1,64 @@ +package com.github.damontecres.wholphin.ui.main + +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +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.nav.Destination +import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.util.LoadingState + +@Composable +fun CustomPagePage( + pageId: String, + title: String, + preferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: CustomPageViewModel = hiltViewModel(), +) { + LaunchedEffect(pageId) { viewModel.load(pageId) } + val state by viewModel.state.collectAsState() + + when (val loading = state.loading) { + is LoadingState.Error -> { + ErrorMessage(loading, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + var position by rememberPosition() + val listState = rememberLazyListState() + HomePageContent( + homeRows = state.rows, + position = position, + onFocusPosition = { position = it }, + onClickItem = { _, item -> + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { _, _ -> }, + onClickPlay = { _, item -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + }, + showClock = preferences.appPreferences.interfacePreferences.showClock, + onUpdateBackdrop = viewModel::updateBackdrop, + showLogo = preferences.appPreferences.interfacePreferences.showLogos, + showViewMore = false, + modifier = modifier, + loadingState = LoadingState.Success, + listState = listState, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/CustomPageViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/CustomPageViewModel.kt new file mode 100644 index 000000000..b99ebd14e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/CustomPageViewModel.kt @@ -0,0 +1,152 @@ +package com.github.damontecres.wholphin.ui.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.PageConfig +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.CachedPageData +import com.github.damontecres.wholphin.services.CustomPageRowsCache +import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ServerPluginApi +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.tvAccess +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import org.jellyfin.sdk.model.api.UserDto +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class CustomPageViewModel + @Inject + constructor( + val navigationManager: NavigationManager, + private val serverRepository: ServerRepository, + private val serverPluginApi: ServerPluginApi, + private val homeSettingsService: HomeSettingsService, + private val navDrawerService: NavDrawerService, + private val userPreferencesService: UserPreferencesService, + private val backdropService: BackdropService, + private val rowsCache: CustomPageRowsCache, + ) : ViewModel() { + private val _state = MutableStateFlow(CustomPageState.EMPTY) + val state: StateFlow = _state + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + fun load(pageId: String) { + viewModelScope.launchIO { + val userDto = serverRepository.currentUserDto.value + if (userDto == null) { + _state.update { it.copy(loading = LoadingState.Error("No active user")) } + return@launchIO + } + + // If we already have this page cached, show it immediately and refresh silently. + val cached = rowsCache.get(userDto.id, pageId) + if (cached != null) { + _state.update { + it.copy( + page = cached.page, + rows = cached.rows, + loading = LoadingState.Success, + ) + } + } else { + _state.update { it.copy(loading = LoadingState.Loading) } + } + + refresh(userDto, pageId, hadCache = cached != null) + } + } + + private suspend fun refresh( + userDto: UserDto, + pageId: String, + hadCache: Boolean, + ) { + val page = + try { + serverPluginApi.fetchPage(pageId) + } catch (ex: Exception) { + Timber.w(ex, "Failed to load custom page %s", pageId) + if (!hadCache) { + _state.update { it.copy(loading = LoadingState.Error("Could not load page", ex)) } + } + return + } + if (page == null) { + if (!hadCache) { + _state.update { it.copy(loading = LoadingState.Error("Page not found")) } + } + return + } + + val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences + val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) + val semaphore = Semaphore(4) + + if (!hadCache) { + _state.update { + it.copy( + page = page, + rows = List(page.rows.size) { HomeRowLoadingState.Pending("") }, + loading = LoadingState.Success, + ) + } + } + + val deferred = + page.rows.map { row -> + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + try { + homeSettingsService.fetchDataForRow( + row = row, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = libraries, + limit = prefs.maxItemsPerRow, + isRefresh = hadCache, + ) + } catch (ex: Exception) { + Timber.w(ex, "Error fetching row in custom page %s", pageId) + HomeRowLoadingState.Error("", exception = ex) + } + } + } + } + val rows = deferred.awaitAll() + _state.update { it.copy(page = page, rows = rows, loading = LoadingState.Success) } + rowsCache.put(userDto.id, pageId, CachedPageData(page, rows)) + } + } + +data class CustomPageState( + val page: PageConfig?, + val rows: List, + val loading: LoadingState, +) { + companion object { + val EMPTY = CustomPageState(null, emptyList(), LoadingState.Pending) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 213527db4..3ed468939 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -138,6 +138,16 @@ sealed class Destination( @Serializable data object Favorites : Destination(false) + /** + * A server-plugin-defined page, identified by its [pageId]. The [title] is passed in to avoid + * a roundtrip just for the header before the page details have been loaded. + */ + @Serializable + data class CustomPage( + val pageId: String, + val title: String, + ) : Destination(false) + @Serializable data object Discover : Destination(false) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 15c957176..1adad6f17 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid +import com.github.damontecres.wholphin.ui.main.CustomPagePage import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage @@ -331,6 +332,15 @@ fun DestinationContent( ) } + is Destination.CustomPage -> { + CustomPagePage( + pageId = destination.pageId, + title = destination.title, + preferences = preferences, + modifier = modifier, + ) + } + Destination.NowPlaying -> { NowPlayingPage(modifier) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 260681828..1884df2ab 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Star import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable @@ -45,6 +46,7 @@ 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.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -70,6 +72,7 @@ import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.ProvideTextStyle 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.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -144,6 +147,13 @@ class NavDrawerViewModel setIndex(index) navigationManager.navigateToFromDrawer(item.destination) } + + is CustomPageNavDrawerItem -> { + setIndex(index) + navigationManager.navigateToFromDrawer( + Destination.CustomPage(item.pageId, item.title), + ) + } } } @@ -176,6 +186,8 @@ class NavDrawerViewModel Destination.Favorites } else if (it is NavDrawerItem.Discover) { Destination.Discover + } else if (it is CustomPageNavDrawerItem) { + Destination.CustomPage(it.pageId, it.title) } else { null } @@ -268,6 +280,20 @@ data class ServerNavDrawerItem( } } +/** + * A page declared by the Wholphin server plugin (see PageConfig in the plugin). Slotted into the + * drawer at the position chosen by the admin via PagePosition; not user-pinnable. + */ +data class CustomPageNavDrawerItem( + val pageId: String, + val title: String, + val iconName: String?, +) : NavDrawerItem { + override val id: String = "p_$pageId" + + override fun name(context: Context): String = title +} + private const val HOME_INDEX = -1 private const val SEARCH_INDEX = -2 private const val NOW_PLAYING_INDEX = -3 @@ -693,6 +719,12 @@ fun NavigationDrawerScope.NavItem( else -> R.string.fa_film } } + + // Dummy resource id for CustomPageNavDrawerItem — it's rendered through a + // dedicated branch in leadingContent below (Material icon or remote image). + is CustomPageNavDrawerItem -> { + R.string.fa_compass + } } } val focused by interactionSource.collectIsFocusedAsState() @@ -707,22 +739,30 @@ fun NavigationDrawerScope.NavItem( leadingContent = { val color = navItemColor(selected, focused, drawerOpen) Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - if (useFont) { - Text( - text = stringResource(icon), - textAlign = TextAlign.Center, - fontSize = 16.sp, - fontFamily = FontAwesome, - color = color, - modifier = Modifier, - ) - } else { - Icon( - painter = painterResource(icon), - contentDescription = null, - tint = color, - modifier = Modifier.size(DrawerIconSize), - ) + when { + library is CustomPageNavDrawerItem -> { + CustomPageIcon(library.iconName, color) + } + + useFont -> { + Text( + text = stringResource(icon), + textAlign = TextAlign.Center, + fontSize = 16.sp, + fontFamily = FontAwesome, + color = color, + modifier = Modifier, + ) + } + + else -> { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = color, + modifier = Modifier.size(DrawerIconSize), + ) + } } } }, @@ -805,3 +845,44 @@ fun navItemColor( val DrawerState.isOpen: Boolean get() = this.currentValue.isOpen val DrawerValue.isOpen: Boolean get() = this == DrawerValue.Open + +/** + * Renders the leading icon for a [CustomPageNavDrawerItem]: + * - http(s):// URL → loaded via Coil (PNG / SVG / etc.) + * - one of a small Material icon name whitelist → drawn as ImageVector + * - anything else / null → [Icons.Default.Star] as fallback + */ +@Composable +private fun CustomPageIcon( + iconName: String?, + tint: Color, +) { + val trimmed = iconName?.trim().orEmpty() + if (trimmed.startsWith("http://", ignoreCase = true) || + trimmed.startsWith("https://", ignoreCase = true) + ) { + AsyncImage( + model = trimmed, + contentDescription = null, + modifier = Modifier.size(DrawerIconSize), + colorFilter = ColorFilter.tint(tint), + ) + return + } + Icon( + imageVector = customPageMaterialIcon(trimmed) ?: Icons.Default.Star, + contentDescription = null, + tint = tint, + modifier = Modifier.size(DrawerIconSize), + ) +} + +private fun customPageMaterialIcon(name: String): ImageVector? = + when (name.lowercase().replace("[_\\s-]".toRegex(), "")) { + "home" -> Icons.Default.Home + "search" -> Icons.Default.Search + "settings" -> Icons.Default.Settings + "star" -> Icons.Default.Star + "play", "playarrow" -> Icons.Default.PlayArrow + else -> null + }