From 384401a72c763c87a30bafca1cf537abd5d3c666 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:20:50 -0500 Subject: [PATCH] Customize display of library grid pages (#368) ## Details Adds options to customize how grid pages are displayed. Every grid page can be individually customized. The options are saved per user on the device and restored when navigating back to the page. The pages that support it have a new button next to the sort/filter buttons and changes are updated live. The options are: * Primary or Thumb image * Aspect ratio * Show details header w/ backdrop (similar to the one on the home page) * Number of columns * Spacing between cards * Card image content scale (eg Fit, Fill, Crop, etc) Note: the Genre tabs' grids cannot be customized yet ### Other changes - Nav drawer now opens over top of content instead of pushing it - Images for most cards are fetched with actual fill size ### Possible future work * Customize Genre grids * Add more image types, banners? * Add similar customization options to pages with rows, eg Home page and Recommended tabs ## Example For example, this is a movie library using thumb images with 8 columns and reduced spacing. It also shows the details header ## Issues Closes #186 Related to #72 --- .../9.json | 320 ++++++++++++++++++ .../damontecres/wholphin/MainActivity.kt | 154 ++++----- .../damontecres/wholphin/data/AppDatabase.kt | 16 +- .../damontecres/wholphin/data/ExtrasItem.kt | 3 - .../wholphin/data/model/BaseItem.kt | 70 +--- .../wholphin/data/model/LibraryDisplayInfo.kt | 2 + .../wholphin/preferences/AppPreference.kt | 158 ++++----- .../wholphin/services/ImageUrlService.kt | 165 +++++++++ .../damontecres/wholphin/ui/Extensions.kt | 9 +- .../damontecres/wholphin/ui/UiConstants.kt | 14 + .../wholphin/ui/cards/BannerCard.kt | 29 +- .../wholphin/ui/cards/EpisodeCard.kt | 2 +- .../wholphin/ui/cards/ExtrasRow.kt | 16 +- .../damontecres/wholphin/ui/cards/GridCard.kt | 5 +- .../wholphin/ui/cards/ItemCardImage.kt | 289 ++++++++++------ .../damontecres/wholphin/ui/cards/ItemRow.kt | 2 +- .../wholphin/ui/cards/SeasonCard.kt | 72 +++- .../ui/components/CollectionFolderGrid.kt | 299 ++++++++++------ .../ui/components/DetailsBackdropImage.kt | 102 ++++++ .../ui/components/SeriesComponents.kt | 41 +++ .../ui/components/ViewOptionsDialog.kt | 232 +++++++++++++ .../ui/detail/CollectionFolderBoxSet.kt | 4 +- .../ui/detail/CollectionFolderGeneric.kt | 11 +- .../ui/detail/CollectionFolderLiveTv.kt | 2 + .../ui/detail/CollectionFolderMovie.kt | 3 + .../ui/detail/CollectionFolderPlaylist.kt | 4 +- .../ui/detail/CollectionFolderRecordings.kt | 4 +- .../wholphin/ui/detail/CollectionFolderTv.kt | 2 + .../wholphin/ui/detail/FavoritesPage.kt | 13 +- .../wholphin/ui/detail/PersonPage.kt | 6 +- .../wholphin/ui/detail/PlaylistDetails.kt | 40 +-- .../ui/detail/episode/EpisodeDetails.kt | 3 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 3 +- .../ui/detail/series/SeriesDetails.kt | 2 +- .../ui/detail/series/SeriesOverviewContent.kt | 2 +- .../damontecres/wholphin/ui/main/HomePage.kt | 63 +--- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 12 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 7 +- .../wholphin/ui/playback/PlaybackPage.kt | 3 +- .../ui/preferences/ComposablePreference.kt | 14 +- .../ui/preferences/PreferenceUtils.kt | 4 +- .../ui/preferences/PreferencesContent.kt | 2 +- .../ui/preferences/SliderPreference.kt | 2 +- .../preferences/subtitle/SubtitleSettings.kt | 25 +- .../wholphin/ui/theme/ThemePreview.kt | 35 +- app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 19 ++ 47 files changed, 1658 insertions(+), 628 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/9.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/9.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/9.json new file mode 100644 index 00000000..7454dfe3 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/9.json @@ -0,0 +1,320 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "4255556f160da7bb269b114400da58da", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4255556f160da7bb269b114400da58da')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 849ee3c5..c24bbeed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -19,7 +20,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore import androidx.lifecycle.lifecycleScope @@ -28,19 +28,20 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.ui.CoilConfig +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.Destination @@ -84,6 +85,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var deviceProfileService: DeviceProfileService + @Inject + lateinit var imageUrlService: ImageUrlService + @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -93,16 +97,6 @@ class MainActivity : AppCompatActivity() { appUpgradeHandler.copySubfont(false) } setContent { - val density = LocalDensity.current - LaunchedEffect(density) { - with(density) { - // Cards are never taller than 200 (most are around 120) - BaseItem.primaryMaxHeight = 200.dp.roundToPx() - // This width covers up to 2.35:1 aspect ratio images - BaseItem.primaryMaxWidth = 480.dp.roundToPx() - } - } - val appPreferences by userPreferencesDataStore.data.collectAsState(null) appPreferences?.let { appPreferences -> CoilConfig( @@ -121,81 +115,83 @@ class MainActivity : AppCompatActivity() { LaunchedEffect(appPreferences.debugLogging) { DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging } - WholphinTheme( - true, - appThemeColors = appPreferences.interfacePreferences.appThemeColors, - ) { - Surface( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - shape = RectangleShape, + CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { + WholphinTheme( + true, + appThemeColors = appPreferences.interfacePreferences.appThemeColors, ) { - var isRestoringSession by remember { mutableStateOf(true) } - LaunchedEffect(Unit) { - try { - serverRepository.restoreSession( - appPreferences.currentServerId?.toUUIDOrNull(), - appPreferences.currentUserId?.toUUIDOrNull(), - ) - } catch (ex: Exception) { - Timber.e(ex, "Exception restoring session") + Surface( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + shape = RectangleShape, + ) { + var isRestoringSession by remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + try { + serverRepository.restoreSession( + appPreferences.currentServerId?.toUUIDOrNull(), + appPreferences.currentUserId?.toUUIDOrNull(), + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception restoring session") + } + isRestoringSession = false } - isRestoringSession = false - } - val current by serverRepository.current.observeAsState() + val current by serverRepository.current.observeAsState() - val preferences = - UserPreferences( - appPreferences, - current?.userDto?.configuration ?: DefaultUserConfiguration, - ) - - if (isRestoringSession) { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), + val preferences = + UserPreferences( + appPreferences, + current?.userDto?.configuration ?: DefaultUserConfiguration, ) - } - } else { - key(current) { - val initialDestination = - if (current != null) { - Destination.Home() - } else { - Destination.ServerList - } - val backStack = rememberNavBackStack(initialDestination) - navigationManager.backStack = backStack - if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { - LaunchedEffect(Unit) { - try { - updateChecker.maybeShowUpdateToast(appPreferences.updateUrl) - } catch (ex: Exception) { - Timber.w(ex, "Failed to check for update") + + if (isRestoringSession) { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } else { + key(current) { + val initialDestination = + if (current != null) { + Destination.Home() + } else { + Destination.ServerList + } + val backStack = rememberNavBackStack(initialDestination) + navigationManager.backStack = backStack + if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { + LaunchedEffect(Unit) { + try { + updateChecker.maybeShowUpdateToast(appPreferences.updateUrl) + } catch (ex: Exception) { + Timber.w(ex, "Failed to check for update") + } } } - } - LaunchedEffect(current, preferences) { - withContext(Dispatchers.IO) { - deviceProfileService.getOrCreateDeviceProfile( - preferences.appPreferences.playbackPreferences, - current?.server?.serverVersion, - ) + LaunchedEffect(current, preferences) { + withContext(Dispatchers.IO) { + deviceProfileService.getOrCreateDeviceProfile( + preferences.appPreferences.playbackPreferences, + current?.server?.serverVersion, + ) + } } + ApplicationContent( + user = current?.user, + server = current?.server, + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) } - ApplicationContent( - user = current?.user, - server = current?.server, - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 272ff86d..8673d1a0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import com.github.damontecres.wholphin.ui.components.ViewOptions import kotlinx.serialization.json.Json import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder @@ -22,7 +23,7 @@ import java.util.UUID @Database( entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class], - version = 8, + version = 9, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -30,6 +31,7 @@ import java.util.UUID AutoMigration(5, 6), AutoMigration(6, 7), AutoMigration(7, 8), + AutoMigration(8, 9), ], ) @TypeConverters(Converters::class) @@ -73,6 +75,18 @@ class Converters { Timber.e(ex, "Error parsing filter") GetItemsFilter() } + + @TypeConverter + fun convertViewOptions(viewOptions: ViewOptions?): String? = viewOptions?.let { Json.encodeToString(viewOptions) } + + @TypeConverter + fun convertViewOptions(viewOptions: String?): ViewOptions? = + try { + viewOptions?.let { Json.decodeFromString(viewOptions) } + } catch (ex: Exception) { + Timber.e(ex, "Error parsing view options") + null + } } object Migrations { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt index 669c56fb..585e1079 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt @@ -13,7 +13,6 @@ sealed interface ExtrasItem { val type: ExtraType val destination: Destination val title: String? - val imageUrl: String? data class Group( override val parentId: UUID, @@ -24,7 +23,6 @@ sealed interface ExtrasItem { Destination.ItemGrid(null, type.stringRes, items.map { it.id }) override val title: String? = null - override val imageUrl: String? = items.random().imageUrl } data class Single( @@ -37,7 +35,6 @@ sealed interface ExtrasItem { item = item, ) override val title: String? get() = item.title - override val imageUrl: String? get() = item.imageUrl } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 5c422313..fb2ed61d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -8,20 +8,15 @@ import com.github.damontecres.wholphin.ui.seasonEpisodePadded import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks -import timber.log.Timber import kotlin.time.Duration @Serializable data class BaseItem( val data: BaseItemDto, - val imageUrl: String?, - val backdropImageUrl: String? = null, - val logoImageUrl: String? = null, + val useSeriesForPrimary: Boolean, ) { val id get() = data.id @@ -95,72 +90,15 @@ data class BaseItem( } companion object { - var primaryMaxWidth: Int? = null - set(value) { - Timber.v("primaryMaxWidth=$value") - field = value - } - var primaryMaxHeight: Int? = null - set(value) { - Timber.v("primaryMaxHeight=$value") - field = value - } - fun from( dto: BaseItemDto, api: ApiClient, useSeriesForPrimary: Boolean = false, - ): BaseItem { - val backdropImageUrl = - if (dto.type == BaseItemKind.EPISODE) { - val seriesId = dto.seriesId - if (seriesId != null) { - api.imageApi.getItemImageUrl(seriesId, ImageType.BACKDROP) - } else { - api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP) - } - } else { - api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP) - } - val primaryImageUrl = - if (useSeriesForPrimary && dto.type == BaseItemKind.EPISODE && dto.seriesId != null) { - api.imageApi.getItemImageUrl( - dto.seriesId!!, - ImageType.PRIMARY, - maxHeight = primaryMaxHeight, - maxWidth = primaryMaxWidth, - quality = 96, - ) - } else if (dto.imageTags == null || dto.imageTags!![ImageType.PRIMARY] == null) { - // TODO is this a bad assumption? - null - } else { - api.imageApi.getItemImageUrl( - dto.id, - ImageType.PRIMARY, - maxHeight = primaryMaxHeight, - maxWidth = primaryMaxWidth, - quality = 96, - ) - } - val logoImageUrl = - if (dto.type == BaseItemKind.EPISODE || dto.type == BaseItemKind.SEASON) { - val seriesId = dto.seriesId - if (seriesId != null) { - api.imageApi.getItemImageUrl(seriesId, ImageType.LOGO) - } else { - api.imageApi.getItemImageUrl(dto.id, ImageType.LOGO) - } - } else { - api.imageApi.getItemImageUrl(dto.id, ImageType.LOGO) - } - return BaseItem( + ): BaseItem = + BaseItem( dto, - primaryImageUrl, - backdropImageUrl, - logoImageUrl, + useSeriesForPrimary, ) - } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt index aaafbbf4..eb8ffdd1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt @@ -7,6 +7,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.Index +import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @@ -36,6 +37,7 @@ data class LibraryDisplayInfo( val direction: SortOrder, @ColumnInfo(defaultValue = "{}") val filter: GetItemsFilter, + val viewOptions: ViewOptions?, ) { @Ignore @Transient val sortAndDirection = SortAndDirection(sort, direction) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 093ddbf2..7b069dd9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -24,7 +24,7 @@ import kotlin.time.Duration.Companion.seconds * * @param T The type of the preference value. */ -sealed interface AppPreference { +sealed interface AppPreference { /** * String resource ID for the title of the preference */ @@ -40,12 +40,12 @@ sealed interface AppPreference { * A function that gets the value from the [AppPreferences] object for UI purposes. This means * that it should return the value that is displayed in the UI, which isn't necessarily the raw value */ - val getter: (prefs: AppPreferences) -> T + val getter: (prefs: Pref) -> T /** * A function that sets the value in the [AppPreferences] object from the UI. It should convert the value if needed */ - val setter: (prefs: AppPreferences, value: T) -> AppPreferences + val setter: (prefs: Pref, value: T) -> Pref fun summary( context: Context, @@ -56,7 +56,7 @@ sealed interface AppPreference { companion object { val SkipForward = - AppSliderPreference( + AppSliderPreference( title = R.string.skip_forward_preference, defaultValue = 30, min = 10, @@ -85,7 +85,7 @@ sealed interface AppPreference { ) val SkipBack = - AppSliderPreference( + AppSliderPreference( title = R.string.skip_back_preference, defaultValue = 10, min = 5, @@ -114,7 +114,7 @@ sealed interface AppPreference { ) // val GridJumpButtons = -// AppSwitchPreference( +// AppSwitchPreference( // title = R.string.show_grid_jump_buttons, // defaultValue = true, // getter = { it.interfacePreferences.showGridJumpButtons }, @@ -126,7 +126,7 @@ sealed interface AppPreference { // ) // val ShowGridFooter = -// AppSwitchPreference( +// AppSwitchPreference( // title = R.string.grid_position_footer, // defaultValue = true, // getter = { it.interfacePreferences.showPositionFooter }, @@ -138,7 +138,7 @@ sealed interface AppPreference { // ) val ControllerTimeout = - AppSliderPreference( + AppSliderPreference( title = R.string.hide_controller_timeout, defaultValue = 5000, min = 500, @@ -159,7 +159,7 @@ sealed interface AppPreference { ) val SeekBarSteps = - AppSliderPreference( + AppSliderPreference( title = R.string.seek_bar_steps, defaultValue = 16, min = 4, @@ -173,7 +173,7 @@ sealed interface AppPreference { ) val HomePageItems = - AppSliderPreference( + AppSliderPreference( title = R.string.max_homepage_items, defaultValue = 25, min = 5, @@ -187,7 +187,7 @@ sealed interface AppPreference { ) val CombineContinueNext = - AppSwitchPreference( + AppSwitchPreference( title = R.string.combine_continue_next, defaultValue = false, getter = { it.homePagePreferences.combineContinueNext }, @@ -199,7 +199,7 @@ sealed interface AppPreference { ) val RewatchNextUp = - AppSwitchPreference( + AppSwitchPreference( title = R.string.rewatch_next_up, defaultValue = false, getter = { it.homePagePreferences.enableRewatchingNextUp }, @@ -211,7 +211,7 @@ sealed interface AppPreference { ) val PlayThemeMusic = - AppChoicePreference( + AppChoicePreference( title = R.string.play_theme_music, defaultValue = ThemeSongVolume.MEDIUM, getter = { it.interfacePreferences.playThemeSongs }, @@ -224,7 +224,7 @@ sealed interface AppPreference { ) val PlaybackDebugInfo = - AppSwitchPreference( + AppSwitchPreference( title = R.string.playback_debug_info, defaultValue = false, getter = { it.playbackPreferences.showDebugInfo }, @@ -236,7 +236,7 @@ sealed interface AppPreference { ) val AutoPlayNextUp = - AppSwitchPreference( + AppSwitchPreference( title = R.string.auto_play_next, defaultValue = true, getter = { it.playbackPreferences.autoPlayNext }, @@ -248,7 +248,7 @@ sealed interface AppPreference { ) val SkipBackOnResume = - AppSliderPreference( + AppSliderPreference( title = R.string.skip_back_on_resume_preference, defaultValue = 0, min = 0, @@ -270,7 +270,7 @@ sealed interface AppPreference { ) val AutoPlayNextDelay = - AppSliderPreference( + AppSliderPreference( title = R.string.auto_play_next_delay, defaultValue = 15, min = 0, @@ -296,7 +296,7 @@ sealed interface AppPreference { ) val PassOutProtection = - AppSliderPreference( + AppSliderPreference( title = R.string.pass_out_protection, defaultValue = 2, min = 0, @@ -341,7 +341,7 @@ sealed interface AppPreference { *(120..200 step 20).map { it * MEGA_BIT }.toTypedArray(), ) val MaxBitrate = - AppSliderPreference( + AppSliderPreference( title = R.string.max_bitrate, defaultValue = bitrateValues.indexOf(DEFAULT_BITRATE).toLong(), min = 0, @@ -370,7 +370,7 @@ sealed interface AppPreference { ) val Ac3Supported = - AppSwitchPreference( + AppSwitchPreference( title = R.string.ac3_supported, defaultValue = true, getter = { it.playbackPreferences.overrides.ac3Supported }, @@ -381,7 +381,7 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) val DownMixStereo = - AppSwitchPreference( + AppSwitchPreference( title = R.string.downmix_stereo, defaultValue = false, getter = { it.playbackPreferences.overrides.downmixStereo }, @@ -392,7 +392,7 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) val DirectPlayAss = - AppSwitchPreference( + AppSwitchPreference( title = R.string.direct_play_ass, defaultValue = true, getter = { it.playbackPreferences.overrides.directPlayAss }, @@ -403,7 +403,7 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) val DirectPlayPgs = - AppSwitchPreference( + AppSwitchPreference( title = R.string.direct_play_pgs, defaultValue = true, getter = { it.playbackPreferences.overrides.directPlayPgs }, @@ -415,7 +415,7 @@ sealed interface AppPreference { ) val RememberSelectedTab = - AppSwitchPreference( + AppSwitchPreference( title = R.string.remember_selected_tab, defaultValue = false, getter = { it.interfacePreferences.rememberSelectedTab }, @@ -427,7 +427,7 @@ sealed interface AppPreference { ) val ThemeColors = - AppChoicePreference( + AppChoicePreference( title = R.string.app_theme, defaultValue = AppThemeColors.PURPLE, getter = { it.interfacePreferences.appThemeColors }, @@ -440,21 +440,21 @@ sealed interface AppPreference { ) val InstalledVersion = - AppClickablePreference( + AppClickablePreference( title = R.string.installed_version, getter = { }, setter = { prefs, _ -> prefs }, ) val Update = - AppClickablePreference( + AppClickablePreference( title = R.string.check_for_updates, getter = { }, setter = { prefs, _ -> prefs }, ) val AutoCheckForUpdates = - AppSwitchPreference( + AppSwitchPreference( title = R.string.auto_check_for_updates, defaultValue = true, getter = { it.autoCheckForUpdates }, @@ -466,7 +466,7 @@ sealed interface AppPreference { ) val UpdateUrl = - AppStringPreference( + AppStringPreference( title = R.string.update_url, defaultValue = "https://api.github.com/repos/damontecres/Wholphin/releases/latest", getter = { it.updateUrl }, @@ -477,19 +477,19 @@ sealed interface AppPreference { ) val OssLicenseInfo = - AppDestinationPreference( + AppDestinationPreference( title = R.string.license_info, destination = Destination.License, ) val AdvancedSettings = - AppDestinationPreference( + AppDestinationPreference( title = R.string.advanced_settings, destination = Destination.Settings(PreferenceScreenOption.ADVANCED), ) val SkipIntros = - AppChoicePreference( + AppChoicePreference( title = R.string.skip_intro_behavior, defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, getter = { it.playbackPreferences.skipIntros }, @@ -502,7 +502,7 @@ sealed interface AppPreference { ) val SkipOutros = - AppChoicePreference( + AppChoicePreference( title = R.string.skip_outro_behavior, defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, getter = { it.playbackPreferences.skipOutros }, @@ -515,7 +515,7 @@ sealed interface AppPreference { ) val SkipCommercials = - AppChoicePreference( + AppChoicePreference( title = R.string.skip_commercials_behavior, defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, getter = { it.playbackPreferences.skipCommercials }, @@ -528,7 +528,7 @@ sealed interface AppPreference { ) val SkipPreviews = - AppChoicePreference( + AppChoicePreference( title = R.string.skip_previews_behavior, defaultValue = SkipSegmentBehavior.IGNORE, getter = { it.playbackPreferences.skipPreviews }, @@ -541,7 +541,7 @@ sealed interface AppPreference { ) val SkipRecaps = - AppChoicePreference( + AppChoicePreference( title = R.string.skip_recap_behavior, defaultValue = SkipSegmentBehavior.IGNORE, getter = { it.playbackPreferences.skipRecaps }, @@ -554,7 +554,7 @@ sealed interface AppPreference { ) val GlobalContentScale = - AppChoicePreference( + AppChoicePreference( title = R.string.global_content_scale, defaultValue = PrefContentScale.FIT, getter = { it.playbackPreferences.globalContentScale }, @@ -567,7 +567,7 @@ sealed interface AppPreference { ) val FfmpegPreference = - AppChoicePreference( + AppChoicePreference( title = R.string.ffmpeg_extension_pref, defaultValue = MediaExtensionStatus.MES_FALLBACK, getter = { it.playbackPreferences.overrides.mediaExtensionsEnabled }, @@ -580,14 +580,14 @@ sealed interface AppPreference { ) val ClearImageCache = - AppClickablePreference( + AppClickablePreference( title = R.string.clear_image_cache, getter = { }, setter = { prefs, _ -> prefs }, ) val UserPinnedNavDrawerItems = - AppClickablePreference( + AppClickablePreference( title = R.string.nav_drawer_pins, summary = R.string.nav_drawer_pins_summary, getter = { }, @@ -595,7 +595,7 @@ sealed interface AppPreference { ) val SendCrashReports = - AppSwitchPreference( + AppSwitchPreference( title = R.string.send_crash_reports, defaultValue = true, getter = { @@ -615,7 +615,7 @@ sealed interface AppPreference { ) val SendAppLogs = - AppClickablePreference( + AppClickablePreference( title = R.string.send_app_logs, summary = R.string.send_app_logs_summary, getter = { }, @@ -623,7 +623,7 @@ sealed interface AppPreference { ) val NavDrawerSwitchOnFocus = - AppSwitchPreference( + AppSwitchPreference( title = R.string.nav_drawer_switch_on_focus, defaultValue = true, getter = { it.interfacePreferences.navDrawerSwitchOnFocus }, @@ -635,7 +635,7 @@ sealed interface AppPreference { ) val ShowNextUpTiming = - AppChoicePreference( + AppChoicePreference( title = R.string.show_next_up_when, defaultValue = ShowNextUpWhen.END_OF_PLAYBACK, getter = { it.playbackPreferences.showNextUpWhen }, @@ -648,7 +648,7 @@ sealed interface AppPreference { ) val ShowClock = - AppSwitchPreference( + AppSwitchPreference( title = R.string.show_clock, defaultValue = true, getter = { it.interfacePreferences.showClock }, @@ -660,7 +660,7 @@ sealed interface AppPreference { ) val OneClickPause = - AppSwitchPreference( + AppSwitchPreference( title = R.string.one_click_pause, defaultValue = false, getter = { it.playbackPreferences.oneClickPause }, @@ -672,13 +672,13 @@ sealed interface AppPreference { ) val SubtitleStyle = - AppDestinationPreference( + AppDestinationPreference( title = R.string.subtitle_style, destination = Destination.Settings(PreferenceScreenOption.SUBTITLES), ) val PlayerBackendPref = - AppChoicePreference( + AppChoicePreference( title = R.string.player_backend, defaultValue = PlayerBackend.EXO_PLAYER, getter = { it.playbackPreferences.playerBackend }, @@ -691,7 +691,7 @@ sealed interface AppPreference { ) val MpvHardwareDecoding = - AppSwitchPreference( + AppSwitchPreference( title = R.string.mpv_hardware_decoding, defaultValue = true, getter = { it.playbackPreferences.mpvOptions.enableHardwareDecoding }, @@ -702,7 +702,7 @@ sealed interface AppPreference { ) val DebugLogging = - AppSwitchPreference( + AppSwitchPreference( title = R.string.verbose_logging, defaultValue = false, getter = { DebugLogTree.INSTANCE.enabled }, @@ -715,7 +715,7 @@ sealed interface AppPreference { ) val ImageDiskCacheSize = - AppSliderPreference( + AppSliderPreference( title = R.string.image_cache_size, defaultValue = 200, min = 25, @@ -884,16 +884,16 @@ val advancedPreferences = ) } -data class AppSwitchPreference( +data class AppSwitchPreference( @get:StringRes override val title: Int, override val defaultValue: Boolean, - override val getter: (prefs: AppPreferences) -> Boolean, - override val setter: (prefs: AppPreferences, value: Boolean) -> AppPreferences, + override val getter: (prefs: Pref) -> Boolean, + override val setter: (prefs: Pref, value: Boolean) -> Pref, val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid }, @param:StringRes val summary: Int? = null, @param:StringRes val summaryOn: Int? = null, @param:StringRes val summaryOff: Int? = null, -) : AppPreference { +) : AppPreference { override fun summary( context: Context, value: Boolean?, @@ -905,70 +905,70 @@ data class AppSwitchPreference( } } -open class AppStringPreference( +open class AppStringPreference( @param:StringRes override val title: Int, override val defaultValue: String, - override val getter: (AppPreferences) -> String, - override val setter: (AppPreferences, String) -> AppPreferences, + override val getter: (Pref) -> String, + override val setter: (Pref, String) -> Pref, @param:StringRes val summary: Int?, -) : AppPreference { +) : AppPreference { override fun summary( context: Context, value: String?, ): String? = summary?.let { context.getString(it) } ?: value } -data class AppChoicePreference( +data class AppChoicePreference( @param:StringRes override val title: Int, override val defaultValue: T, @param:ArrayRes val displayValues: Int, val indexToValue: (index: Int) -> T, val valueToIndex: (T) -> Int, - override val getter: (prefs: AppPreferences) -> T, - override val setter: (prefs: AppPreferences, value: T) -> AppPreferences, + override val getter: (prefs: Pref) -> T, + override val setter: (prefs: Pref, value: T) -> Pref, @param:StringRes val summary: Int? = null, -) : AppPreference +) : AppPreference -data class AppMultiChoicePreference( +data class AppMultiChoicePreference( @param:StringRes override val title: Int, override val defaultValue: List, val allValues: List, @param:ArrayRes val displayValues: Int, - override val getter: (prefs: AppPreferences) -> List, - override val setter: (prefs: AppPreferences, value: List) -> AppPreferences, + override val getter: (prefs: Pref) -> List, + override val setter: (prefs: Pref, value: List) -> Pref, @param:StringRes val summary: Int? = null, val toSharedPrefs: (T) -> String, val fromSharedPrefs: (String) -> T?, -) : AppPreference> +) : AppPreference> -data class AppClickablePreference( +data class AppClickablePreference( @param:StringRes override val title: Int, override val defaultValue: Unit = Unit, - override val getter: (prefs: AppPreferences) -> Unit = { }, - override val setter: (prefs: AppPreferences, value: Unit) -> AppPreferences = { prefs, _ -> prefs }, + override val getter: (prefs: Pref) -> Unit = { }, + override val setter: (prefs: Pref, value: Unit) -> Pref = { prefs, _ -> prefs }, @param:StringRes val summary: Int? = null, -) : AppPreference { +) : AppPreference { override fun summary( context: Context, value: Unit?, ): String? = summary?.let { context.getString(it) } } -data class AppDestinationPreference( +data class AppDestinationPreference( @param:StringRes override val title: Int, override val defaultValue: Unit = Unit, - override val getter: (prefs: AppPreferences) -> Unit = { }, - override val setter: (prefs: AppPreferences, value: Unit) -> AppPreferences = { prefs, _ -> prefs }, + override val getter: (prefs: Pref) -> Unit = { }, + override val setter: (prefs: Pref, value: Unit) -> Pref = { prefs, _ -> prefs }, @param:StringRes val summary: Int? = null, val destination: Destination, -) : AppPreference { +) : AppPreference { override fun summary( context: Context, value: Unit?, ): String? = summary?.let { context.getString(it) } } -class AppSliderPreference( +class AppSliderPreference( @param:StringRes override val title: Int, override val defaultValue: Long, /** @@ -980,11 +980,11 @@ class AppSliderPreference( */ val max: Long = 100, val interval: Int = 1, - override val getter: (prefs: AppPreferences) -> Long, - override val setter: (prefs: AppPreferences, value: Long) -> AppPreferences, + override val getter: (prefs: Pref) -> Long, + override val setter: (prefs: Pref, value: Long) -> Pref, @param:StringRes val summary: Int? = null, val summarizer: ((Long?) -> String?)? = null, -) : AppPreference { +) : AppPreference { override fun summary( context: Context, value: Long?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt new file mode 100644 index 00000000..7dfa025c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -0,0 +1,165 @@ +package com.github.damontecres.wholphin.services + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.github.damontecres.wholphin.data.model.BaseItem +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.imageApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageFormat +import org.jellyfin.sdk.model.api.ImageType +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Provides image URLs for items with several convenience methods + * + * This is available in compose UI code via [com.github.damontecres.wholphin.ui.LocalImageUrlService] + */ +@Singleton +class ImageUrlService + @Inject + constructor( + private val api: ApiClient, + ) { + fun getItemImageUrl( + itemId: UUID, + itemType: BaseItemKind, + seriesId: UUID?, + useSeriesForPrimary: Boolean, + imageType: ImageType, + fillWidth: Int? = null, + fillHeight: Int? = null, + ): String? = + when (imageType) { + ImageType.BACKDROP, + ImageType.LOGO, + -> { + if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { + getItemImageUrl( + itemId = seriesId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + getItemImageUrl( + itemId = itemId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } + } + + ImageType.PRIMARY, + ImageType.THUMB, + ImageType.BANNER, + -> { + if (useSeriesForPrimary && seriesId != null && + (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) + ) { + getItemImageUrl( + itemId = seriesId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + getItemImageUrl( + itemId = itemId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } + } + + else -> + getItemImageUrl( + itemId = itemId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } + + fun getItemImageUrl( + item: BaseItem?, + imageType: ImageType, + fillWidth: Int? = null, + fillHeight: Int? = null, + ): String? = + if (item != null) { + getItemImageUrl( + itemId = item.id, + itemType = item.type, + seriesId = item.data.seriesId, + useSeriesForPrimary = item.useSeriesForPrimary, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + null + } + + fun getItemImageUrl( + itemId: UUID, + imageType: ImageType, + maxWidth: Int? = null, + maxHeight: Int? = null, + width: Int? = null, + height: Int? = null, + quality: Int? = QUALITY, + fillWidth: Int? = null, + fillHeight: Int? = null, + tag: String? = null, + format: ImageFormat? = null, + percentPlayed: Double? = null, + unplayedCount: Int? = null, + blur: Int? = null, + backgroundColor: String? = null, + foregroundLayer: String? = null, + imageIndex: Int? = null, + ): String = + api.imageApi.getItemImageUrl( + itemId = itemId, + imageType = imageType, + maxWidth = maxWidth, + maxHeight = maxHeight, + width = width, + height = height, + quality = quality, + fillWidth = fillWidth, + fillHeight = fillHeight, + tag = tag, + format = format, + percentPlayed = percentPlayed, + unplayedCount = unplayedCount, + blur = blur, + backgroundColor = backgroundColor, + foregroundLayer = foregroundLayer, + imageIndex = imageIndex, + ) + + /** + * Just a convenient way to get the image URL and remember it + */ + @Composable + fun rememberImageUrl( + item: BaseItem?, + imageType: ImageType = ImageType.PRIMARY, + ) = remember(item, imageType) { + if (item != null) { + getItemImageUrl(item, imageType) + } else { + null + } + } + + companion object { + private const val QUALITY = 96 + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index a8ceb0b2..f1ebfc06 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -44,6 +44,7 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID @@ -308,7 +309,7 @@ fun logCoilError( url: String?, errorResult: ErrorResult, ) { - if (errorResult.throwable is coil3.network.HttpException) { + if (errorResult.throwable is coil3.network.HttpException || errorResult.throwable is coil3.request.NullRequestDataException) { Timber.w("Error loading image: %s for %s", errorResult.throwable.localizedMessage, url) } else { Timber.e(errorResult.throwable, "Error loading image: %s", url) @@ -401,3 +402,9 @@ fun Response.toBaseItems( api: ApiClient, useSeriesForPrimary: Boolean, ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } + +@Composable +fun rememberBackDropImage(item: BaseItem) { + val imageUrlService = LocalImageUrlService.current + return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index c15fe0b4..b01aad1d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -2,18 +2,23 @@ package com.github.damontecres.wholphin.ui import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.ImageUrlService import org.jellyfin.sdk.model.api.ItemFields // This file is for constants used for the UI val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900)) +val LocalImageUrlService = + staticCompositionLocalOf { throw IllegalStateException("LocalImageUrlService not set") } + /** * Colors not associated with the theme */ @@ -81,6 +86,15 @@ object AspectRatios { const val SQUARE = 1f } +enum class AspectRatio( + val ratio: Float, +) { + TALL(AspectRatios.TALL), + WIDE(AspectRatios.WIDE), + FOUR_THREE(AspectRatios.FOUR_THREE), + SQUARE(AspectRatios.SQUARE), +} + @Preview( device = "spec:parent=tv_1080p", backgroundColor = 0xFF383535, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 4f22310e..ac0bbece 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -33,11 +34,14 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import org.jellyfin.sdk.model.api.ImageType /** * Displays an image as a card. If no image is available, the name will be shown instead @@ -45,7 +49,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank @Composable fun BannerCard( name: String?, - imageUrl: String?, + item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, @@ -57,6 +61,29 @@ fun BannerCard( aspectRatio: Float = AspectRatios.WIDE, interactionSource: MutableInteractionSource? = null, ) { + val imageUrlService = LocalImageUrlService.current + val density = LocalDensity.current + val imageUrl = + remember(item, cardHeight) { + if (item != null) { + val fillHeight = + if (cardHeight != Dp.Unspecified) { + with(density) { + cardHeight.roundToPx() + } + } else { + null + } + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + fillWidth = null, + fillHeight = fillHeight, + ) + } else { + null + } + } var imageError by remember { mutableStateOf(false) } Card( modifier = modifier.size(cardHeight * aspectRatio, cardHeight), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index 6d25aa80..a7df3434 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -90,7 +90,7 @@ fun EpisodeCard( .fillMaxSize(), ) { ItemCardImage( - imageUrl = item?.imageUrl, + item = item, name = item?.name, showOverlay = false, favorite = dto?.userData?.isFavorite ?: false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt index fcfba675..39750da3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.cards import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources @@ -11,6 +12,8 @@ import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.pluralRes import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import org.jellyfin.sdk.model.api.ImageType @Composable fun ExtrasRow( @@ -27,6 +30,17 @@ fun ExtrasRow( onClickItem = onClickItem, onLongClickItem = onLongClickItem, cardContent = { index, item, mod, onClick, onLongClick -> + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember { + val item = + when (item) { + is ExtrasItem.Group -> item.items.random() + is ExtrasItem.Single -> item.item + null -> null + } + imageUrlService.getItemImageUrl(item, ImageType.PRIMARY) + } SeasonCard( title = when (item) { @@ -49,7 +63,7 @@ fun ExtrasRow( showImageOverlay = true, imageHeight = Cards.height2x3 * .75f, imageWidth = Dp.Unspecified, - imageUrl = item?.imageUrl, + imageUrl = imageUrl, isFavorite = false, isPlayed = false, unplayedItemCount = -1, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index 66377a3c..b6f3ff4f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -27,6 +27,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.enableMarquee import kotlinx.coroutines.delay @@ -42,6 +43,7 @@ fun GridCard( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, imageAspectRatio: Float = AspectRatios.TALL, imageContentScale: ContentScale = ContentScale.Fit, + imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, ) { val dto = item?.data val focused by interactionSource.collectIsFocusedAsState() @@ -78,7 +80,8 @@ fun GridCard( ), ) { ItemCardImage( - imageUrl = item?.imageUrl, + item = item, + imageType = imageType.imageType, name = item?.name, // showOverlay = !focusedAfterDelay, showOverlay = true, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 86296197..9254fbd3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -1,12 +1,10 @@ package com.github.damontecres.wholphin.ui.cards -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -26,20 +24,79 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onLayoutRectChanged import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.logCoilError +import org.jellyfin.sdk.model.api.ImageType + +/** + * Display an image for an item with optional overlay data + * + * This will fetch the image using fillWidth/fillHeight based on the layout size + */ +@Composable +fun ItemCardImage( + item: BaseItem?, + name: String?, + showOverlay: Boolean, + favorite: Boolean, + watched: Boolean, + unwatchedCount: Int, + watchedPercent: Double?, + modifier: Modifier = Modifier, + imageType: ImageType = ImageType.PRIMARY, + useFallbackText: Boolean = true, + contentScale: ContentScale = ContentScale.Fit, +) { + val imageUrlService = LocalImageUrlService.current + var size by remember { mutableStateOf(IntSize.Zero) } + val imageUrl = + remember(size, item) { + if (size != IntSize.Zero && item != null) { + imageUrlService.getItemImageUrl( + item, + imageType, + fillWidth = size.width, + fillHeight = size.height, + ) + } else { + null + } + } + ItemCardImage( + imageUrl = imageUrl, + name = name, + showOverlay = showOverlay, + favorite = favorite, + watched = watched, + unwatchedCount = unwatchedCount, + watchedPercent = watchedPercent, + modifier = + modifier.onLayoutRectChanged( + throttleMillis = 100, + debounceMillis = 25, + ) { + size = IntSize(width = it.width, height = it.height) + }, + useFallbackText = useFallbackText, + contentScale = contentScale, + ) +} @Composable fun ItemCardImage( @@ -54,8 +111,10 @@ fun ItemCardImage( useFallbackText: Boolean = true, contentScale: ContentScale = ContentScale.Fit, ) { - var imageError by remember { mutableStateOf(false) } - Box(modifier = modifier) { + var imageError by remember(imageUrl) { mutableStateOf(false) } + Box( + modifier = modifier, + ) { if (!imageError && imageUrl.isNotNullOrBlank()) { AsyncImage( model = imageUrl, @@ -72,104 +131,130 @@ fun ItemCardImage( .align(Alignment.TopCenter), ) } else { - // TODO options for overriding fallback - Box( - modifier = - Modifier - .background(MaterialTheme.colorScheme.surfaceVariant) - .fillMaxSize() - .align(Alignment.TopCenter), - ) { - if (useFallbackText && name.isNotNullOrBlank()) { - Text( - text = name, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 14.sp, - modifier = - Modifier - .padding(8.dp) - .align(Alignment.Center), - ) - } else { - Image( - painter = painterResource(id = R.drawable.video_solid), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = - ColorFilter.tint( - MaterialTheme.colorScheme.onSurfaceVariant, - BlendMode.SrcIn, - ), - modifier = - Modifier - .fillMaxSize(.4f) - .align(Alignment.Center), - ) - } - } + ItemCardImageFallback( + name = name, + useFallbackText = useFallbackText, + modifier = Modifier, + ) } - AnimatedVisibility( - visible = showOverlay, - enter = fadeIn(), - exit = fadeOut(), - ) { - Box(modifier = Modifier.fillMaxSize()) { - if (favorite) { - Text( - modifier = - Modifier - .align(Alignment.TopStart) - .padding(8.dp), - color = colorResource(android.R.color.holo_red_light), - text = stringResource(R.string.fa_heart), - fontSize = 20.sp, - fontFamily = FontAwesome, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(4.dp) - .align(Alignment.TopEnd), - ) { - if (watched && (watchedPercent == null || watchedPercent <= 0.0 || watchedPercent >= 100.0)) { - WatchedIcon(Modifier.size(24.dp)) - } - if (unwatchedCount > 0) { - Box( - modifier = - Modifier - .background( - AppColors.TransparentBlack50, - shape = RoundedCornerShape(25), - ), - ) { - Text( - text = unwatchedCount.toString(), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium, -// fontSize = 16.sp, - modifier = Modifier.padding(4.dp), - ) - } - } - } - - watchedPercent?.let { percent -> - Box( - modifier = - Modifier - .align(Alignment.BottomStart) - .background( - MaterialTheme.colorScheme.tertiary, - ).clip(RectangleShape) - .height(Cards.playedPercentHeight) - .fillMaxWidth((percent / 100.0).toFloat()), - ) - } - } + if (showOverlay) { + ItemCardImageOverlay( + favorite = favorite, + watched = watched, + unwatchedCount = unwatchedCount, + watchedPercent = watchedPercent, + modifier = Modifier, + ) + } + } +} + +@Composable +fun BoxScope.ItemCardImageFallback( + name: String?, + useFallbackText: Boolean, + modifier: Modifier = Modifier, +) { + // TODO options for overriding fallback + Box( + modifier = + modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .fillMaxSize() + .align(Alignment.TopCenter), + ) { + if (useFallbackText && name.isNotNullOrBlank()) { + Text( + text = name, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + modifier = + Modifier + .padding(8.dp) + .align(Alignment.Center), + ) + } else { + Image( + painter = painterResource(id = R.drawable.video_solid), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = + ColorFilter.tint( + MaterialTheme.colorScheme.onSurfaceVariant, + BlendMode.SrcIn, + ), + modifier = + Modifier + .fillMaxSize(.4f) + .align(Alignment.Center), + ) + } + } +} + +@Composable +fun ItemCardImageOverlay( + favorite: Boolean, + watched: Boolean, + unwatchedCount: Int, + watchedPercent: Double?, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + if (favorite) { + Text( + modifier = + Modifier + .align(Alignment.TopStart) + .padding(8.dp), + color = colorResource(android.R.color.holo_red_light), + text = stringResource(R.string.fa_heart), + fontSize = 20.sp, + fontFamily = FontAwesome, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(4.dp) + .align(Alignment.TopEnd), + ) { + if (watched && (watchedPercent == null || watchedPercent <= 0.0 || watchedPercent >= 100.0)) { + WatchedIcon(Modifier.size(24.dp)) + } + if (unwatchedCount > 0) { + Box( + modifier = + Modifier + .background( + AppColors.TransparentBlack50, + shape = RoundedCornerShape(25), + ), + ) { + Text( + text = unwatchedCount.toString(), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, +// fontSize = 16.sp, + modifier = Modifier.padding(4.dp), + ) + } + } + } + + watchedPercent?.let { percent -> + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .background( + MaterialTheme.colorScheme.tertiary, + ).clip(RectangleShape) + .height(Cards.playedPercentHeight) + .fillMaxWidth((percent / 100.0).toFloat()), + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index e52fdd0c..26bc9ce5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -111,7 +111,7 @@ fun BannerItemRow( cardContent = { index, item, modifier, onClick, onLongClick -> BannerCard( name = title, - imageUrl = item?.imageUrl, + item = item, aspectRatio = aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.WIDE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index ed9d01cd..66f93212 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -27,8 +28,10 @@ import androidx.tv.material3.CardDefaults import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.enableMarquee import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.api.ImageType /** * A Card for a TV Show Season, but can generically show most items @@ -44,24 +47,57 @@ fun SeasonCard( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, showImageOverlay: Boolean = false, aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL, -) = SeasonCard( - title = item?.title, - subtitle = item?.subtitle, - name = item?.name, - imageUrl = item?.imageUrl, - isFavorite = item?.data?.userData?.isFavorite ?: false, - isPlayed = item?.data?.userData?.played ?: false, - unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, - playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier, - imageHeight = imageHeight, - imageWidth = imageWidth, - interactionSource = interactionSource, - showImageOverlay = showImageOverlay, - aspectRatio = aspectRatio, -) +) { + val imageUrlService = LocalImageUrlService.current + val density = LocalDensity.current + val imageUrl = + remember(item, imageHeight, imageWidth) { + if (item != null) { + val fillHeight = + if (imageHeight != Dp.Unspecified) { + with(density) { + imageHeight.roundToPx() + } + } else { + null + } + val fillWidth = + if (imageWidth != Dp.Unspecified) { + with(density) { + imageWidth.roundToPx() + } + } else { + null + } + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + null + } + } + SeasonCard( + title = item?.title, + subtitle = item?.subtitle, + name = item?.name, + imageUrl = imageUrl, + isFavorite = item?.data?.userData?.isFavorite ?: false, + isPlayed = item?.data?.userData?.played ?: false, + unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, + playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + imageHeight = imageHeight, + imageWidth = imageWidth, + interactionSource = interactionSource, + showImageOverlay = showImageOverlay, + aspectRatio = aspectRatio, + ) +} /** * A Card for a TV Show Season, but can generically show most items diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index d9d8509d..e31b1b45 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -75,7 +76,11 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.scale +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager @@ -125,6 +130,7 @@ class CollectionFolderViewModel val pager = MutableLiveData>(listOf()) val sortAndDirection = MutableLiveData() val filter = MutableLiveData(GetItemsFilter()) + val viewOptions = MutableLiveData() private var useSeriesForPrimary: Boolean = true @@ -134,6 +140,7 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, useSeriesForPrimary: Boolean, + defaultViewOptions: ViewOptions, ): Job = viewModelScope.launch( LoadingExceptionHandler( @@ -151,6 +158,9 @@ class CollectionFolderViewModel serverRepository.currentUser.value?.let { user -> libraryDisplayInfoDao.getItem(user, itemId) } + this@CollectionFolderViewModel.viewOptions.setValueOnMain( + libraryDisplayInfo?.viewOptions ?: defaultViewOptions, + ) val sortAndDirection = libraryDisplayInfo?.sortAndDirection @@ -167,24 +177,40 @@ class CollectionFolderViewModel loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) } - fun onFilterChange( - newFilter: GetItemsFilter, - recursive: Boolean, + private fun saveLibraryDisplayInfo( + newFilter: GetItemsFilter = this.filter.value!!, + newSort: SortAndDirection = this.sortAndDirection.value!!, + viewOptions: ViewOptions? = this.viewOptions.value, ) { - Timber.v("onFilterChange: filter=%s", newFilter) serverRepository.currentUser.value?.let { user -> viewModelScope.launch(Dispatchers.IO) { val libraryDisplayInfo = LibraryDisplayInfo( userId = user.rowId, itemId = itemId, - sort = sortAndDirection.value!!.sort, - direction = sortAndDirection.value!!.direction, + sort = newSort.sort, + direction = newSort.direction, filter = newFilter, + viewOptions = viewOptions, ) libraryDisplayInfoDao.saveItem(libraryDisplayInfo) } } + } + + fun saveViewOptions(viewOptions: ViewOptions) { + this.viewOptions.value = viewOptions + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + saveLibraryDisplayInfo(viewOptions = viewOptions) + } + } + + fun onFilterChange( + newFilter: GetItemsFilter, + recursive: Boolean, + ) { + Timber.v("onFilterChange: filter=%s", newFilter) + saveLibraryDisplayInfo(newFilter, sortAndDirection.value!!) loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary) } @@ -199,19 +225,7 @@ class CollectionFolderViewModel recursive, filter, ) - serverRepository.currentUser.value?.let { user -> - viewModelScope.launch(Dispatchers.IO) { - val libraryDisplayInfo = - LibraryDisplayInfo( - userId = user.rowId, - itemId = itemId, - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - filter = filter, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) - } - } + saveLibraryDisplayInfo(filter, sortAndDirection) loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary) } @@ -454,11 +468,11 @@ fun CollectionFolderGrid( onClickItem: (Int, BaseItem) -> Unit, sortOptions: List, playEnabled: Boolean, + defaultViewOptions: ViewOptions, modifier: Modifier = Modifier, initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - params: CollectionFolderGridParameters = CollectionFolderGridParameters(), useSeriesForPrimary: Boolean = true, filterOptions: List> = DefaultFilterOptions, ) = CollectionFolderGrid( @@ -469,11 +483,11 @@ fun CollectionFolderGrid( onClickItem, sortOptions, playEnabled, - modifier, + defaultViewOptions = defaultViewOptions, + modifier = modifier, initialSortAndDirection = initialSortAndDirection, showTitle = showTitle, positionCallback = positionCallback, - params = params, useSeriesForPrimary = useSeriesForPrimary, filterOptions = filterOptions, ) @@ -487,13 +501,13 @@ fun CollectionFolderGrid( onClickItem: (Int, BaseItem) -> Unit, sortOptions: List, playEnabled: Boolean, + defaultViewOptions: ViewOptions, modifier: Modifier = Modifier, viewModel: CollectionFolderViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - params: CollectionFolderGridParameters = CollectionFolderGridParameters(), useSeriesForPrimary: Boolean = true, filterOptions: List> = DefaultFilterOptions, ) { @@ -505,6 +519,7 @@ fun CollectionFolderGrid( recursive, initialFilter, useSeriesForPrimary, + defaultViewOptions, ) } val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) @@ -513,6 +528,7 @@ fun CollectionFolderGrid( val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() val pager by viewModel.pager.observeAsState() + val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions) var moreDialog by remember { mutableStateOf>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } @@ -551,7 +567,9 @@ fun CollectionFolderGrid( sortOptions = sortOptions, positionCallback = positionCallback, letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, - params = params, + viewOptions = viewOptions, + defaultViewOptions = defaultViewOptions, + onSaveViewOptions = { viewModel.saveViewOptions(it) }, playEnabled = playEnabled, onClickPlay = { shuffle -> itemId.toUUIDOrNull()?.let { @@ -656,117 +674,174 @@ fun CollectionFolderGridContent( modifier: Modifier = Modifier, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - params: CollectionFolderGridParameters = CollectionFolderGridParameters(), currentFilter: GetItemsFilter = GetItemsFilter(), filterOptions: List> = listOf(), onFilterChange: (GetItemsFilter) -> Unit = {}, getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + defaultViewOptions: ViewOptions, + viewOptions: ViewOptions, + onSaveViewOptions: (ViewOptions) -> Unit, ) { + val context = LocalContext.current val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) var showHeader by rememberSaveable { mutableStateOf(true) } + var showViewOptions by rememberSaveable { mutableStateOf(false) } + var viewOptions by remember { mutableStateOf(viewOptions) } val gridFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - Column( - verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = modifier, - ) { - AnimatedVisibility( - showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + var backdropImageUrl by remember { mutableStateOf(null) } + + var position by rememberInt(0) + val focusedItem = pager.getOrNull(position) + + Box(modifier = modifier) { + if (viewOptions.showDetails) { + DelayedDetailsBackdropImage( + item = focusedItem, + modifier = Modifier, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.fillMaxSize(), ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), ) { - if (showTitle) { - Text( - text = title, - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - } - val endPadding = - 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(start = 16.dp, end = endPadding) - .fillMaxWidth(), + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), ) { - if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier, - ) { - if (sortOptions.isNotEmpty()) { - SortByButton( - sortOptions = sortOptions, - current = sortAndDirection, - onSortChange = onSortChange, - modifier = Modifier, - ) - } - if (filterOptions.isNotEmpty()) { - FilterByButton( - filterOptions = filterOptions, - current = currentFilter, - onFilterChange = onFilterChange, - getPossibleValues = getPossibleFilterValues, + if (showTitle) { + Text( + text = title, + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + val endPadding = + 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = endPadding) + .fillMaxWidth(), + ) { + if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + if (sortOptions.isNotEmpty()) { + SortByButton( + sortOptions = sortOptions, + current = sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier, + ) + } + if (filterOptions.isNotEmpty()) { + FilterByButton( + filterOptions = filterOptions, + current = currentFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier, + ) + } + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = { showViewOptions = true }, modifier = Modifier, ) } } - } - if (playEnabled) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier, - ) { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = { onClickPlay.invoke(false) }, - ) - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { onClickPlay.invoke(true) }, - ) + if (playEnabled) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { onClickPlay.invoke(false) }, + ) + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { onClickPlay.invoke(true) }, + ) + } } } } } + AnimatedVisibility(viewOptions.showDetails) { + HomePageHeader( + item = focusedItem, + modifier = + Modifier + .fillMaxWidth(.6f) +// .fillMaxHeight(.25f) + .height(140.dp) + .padding(16.dp), + ) + } + CardGrid( + pager = pager, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + letterPosition = letterPosition, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, // TODO add preference + showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, + modifier = Modifier.fillMaxSize(), + initialPosition = 0, + positionCallback = { columns, newPosition -> + showHeader = newPosition < columns + position = newPosition + positionCallback?.invoke(columns, newPosition) + }, + cardContent = { item, onClick, onLongClick, mod -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = viewOptions.contentScale.scale, + imageAspectRatio = viewOptions.aspectRatio.ratio, + imageType = viewOptions.imageType, + modifier = mod, + ) + }, + columns = viewOptions.columns, + spacing = viewOptions.spacing.dp, + ) + AnimatedVisibility(showViewOptions) { + ViewOptionsDialog( + viewOptions = viewOptions, + defaultViewOptions = defaultViewOptions, + onDismissRequest = { + showViewOptions = false + onSaveViewOptions.invoke(viewOptions) + }, + onViewOptionsChange = { viewOptions = it }, + ) + } } - CardGrid( - pager = pager, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - letterPosition = letterPosition, - gridFocusRequester = gridFocusRequester, - showJumpButtons = false, // TODO add preference - showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, - modifier = Modifier.fillMaxSize(), - initialPosition = 0, - positionCallback = { columns, position -> - showHeader = position < columns - positionCallback?.invoke(columns, position) - }, - cardContent = params.cardContent, - columns = params.columns, - spacing = params.spacing, - ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt index c02f7f22..eea7fa90 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DetailsBackdropImage.kt @@ -2,7 +2,13 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -10,9 +16,35 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.tv.material3.MaterialTheme import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.transitionFactory +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.CrossFadeFactory +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.api.ImageType +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun BoxScope.DetailsBackdropImage( + item: BaseItem?, + modifier: Modifier = Modifier, +) { + val imageUrlService = LocalImageUrlService.current + val backdropImageUrl = + remember(item) { + if (item != null) { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + } else { + null + } + } + DetailsBackdropImage(backdropImageUrl, modifier) +} @Composable fun BoxScope.DetailsBackdropImage( @@ -50,3 +82,73 @@ fun BoxScope.DetailsBackdropImage( ) } } + +@Composable +fun BoxScope.DelayedDetailsBackdropImage( + item: BaseItem?, + modifier: Modifier = Modifier, +) { + val imageUrlService = LocalImageUrlService.current + val backdropImageUrl = + remember(item) { + if (item != null) { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + } else { + null + } + } + DelayedDetailsBackdropImage(backdropImageUrl, modifier) +} + +/** + * Shows a backdrop image, but with a crossfade & delay + * + * Used for change backdrops when change items frequently + */ +@Composable +fun BoxScope.DelayedDetailsBackdropImage( + focusedBackdropImageUrl: String?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var backdropImageUrl by remember { mutableStateOf(null) } + LaunchedEffect(focusedBackdropImageUrl) { + backdropImageUrl = null + delay(150) + backdropImageUrl = focusedBackdropImageUrl + } + val gradientColor = MaterialTheme.colorScheme.background + AsyncImage( + model = + ImageRequest + .Builder(context) + .data(backdropImageUrl) + .transitionFactory(CrossFadeFactory(250.milliseconds)) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = Alignment.TopEnd, + modifier = + modifier + .fillMaxHeight(.7f) + .fillMaxWidth(.7f) + .alpha(.75f) + .align(Alignment.TopEnd) + .drawWithContent { + drawContent() + drawRect( + Brush.verticalGradient( + colors = listOf(Color.Transparent, gradientColor), + startY = size.height * .33f, + ), + ) + drawRect( + Brush.horizontalGradient( + colors = listOf(gradientColor, Color.Transparent), + startX = 0f, + endX = size.width * .5f, + ), + ) + }, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt index 8fb63ef6..99dbb53b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -76,3 +76,44 @@ fun EpisodeQuickDetails( modifier = modifier, ) } + +@Composable +fun SeriesQuickDetails( + dto: BaseItemDto?, + modifier: Modifier = Modifier, +) { + val details = + remember(dto) { + buildList { + if (dto?.productionYear != null) { + val date = + buildString { + append(dto.productionYear.toString()) + if (dto.status == "Continuing") { + append(" - ") + append("Present") + } else if (dto.status == "Ended") { + dto.endDate?.let { + append(" - ") + append(it.year) + } + } + } + add(date) + } + val duration = dto?.runTimeTicks?.ticks + duration + ?.roundMinutes + ?.toString() + ?.let(::add) + dto?.officialRating?.let(::add) + } + } + DotSeparatedRow( + texts = details, + communityRating = dto?.communityRating, + criticRating = dto?.criticRating, + textStyle = MaterialTheme.typography.titleSmall, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt new file mode 100644 index 00000000..8142002b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt @@ -0,0 +1,232 @@ +package com.github.damontecres.wholphin.ui.components + +import android.view.Gravity +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.AppChoicePreference +import com.github.damontecres.wholphin.preferences.AppClickablePreference +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppSliderPreference +import com.github.damontecres.wholphin.preferences.AppSwitchPreference +import com.github.damontecres.wholphin.preferences.PrefContentScale +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.serialization.Serializable +import org.jellyfin.sdk.model.api.ImageType + +@Composable +fun ViewOptionsDialog( + viewOptions: ViewOptions, + onDismissRequest: () -> Unit, + onViewOptionsChange: (ViewOptions) -> Unit, + defaultViewOptions: ViewOptions = ViewOptions(), +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val columnState = rememberLazyListState() + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.END) + window.setDimAmount(0f) + } + LazyColumn( + state = columnState, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .width(256.dp) + .heightIn(max = 380.dp) + .focusRequester(focusRequester) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + stickyHeader { + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + ) + } + items(ViewOptions.OPTIONS) { pref -> + pref as AppPreference + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(viewOptions) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + onClickPreference = { pref -> + if (pref == ViewOptions.ViewOptionsReset) { + onViewOptionsChange.invoke(defaultViewOptions) + } + }, + ) + } + } + } +} + +@Serializable +data class ViewOptions( + val columns: Int = 6, + val spacing: Int = 16, + val contentScale: PrefContentScale = PrefContentScale.FIT, + val aspectRatio: AspectRatio = AspectRatio.TALL, + val showDetails: Boolean = false, + val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, +) { + companion object { + val ViewOptionsColumns = + AppSliderPreference( + title = R.string.columns, + defaultValue = 6, + min = 1, + max = 12, + interval = 1, + getter = { it.columns.toLong() }, + setter = { prefs, value -> prefs.copy(columns = value.toInt()) }, + ) + val ViewOptionsSpacing = + AppSliderPreference( + title = R.string.spacing, + defaultValue = 16, + min = 0, + max = 32, + interval = 2, + getter = { it.spacing.toLong() }, + setter = { prefs, value -> prefs.copy(spacing = value.toInt()) }, + ) + + val ViewOptionsContentScale = + AppChoicePreference( + title = R.string.global_content_scale, + defaultValue = PrefContentScale.FIT, + displayValues = R.array.content_scale, + getter = { it.contentScale }, + setter = { viewOptions, value -> viewOptions.copy(contentScale = value) }, + indexToValue = { PrefContentScale.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val ViewOptionsAspectRatio = + AppChoicePreference( + title = R.string.aspect_ratio, + defaultValue = AspectRatio.TALL, + displayValues = R.array.aspect_ratios, + getter = { it.aspectRatio }, + setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) }, + indexToValue = { AspectRatio.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsDetailHeader = + AppSwitchPreference( + title = R.string.show_details, + defaultValue = false, + getter = { it.showDetails }, + setter = { vo, value -> vo.copy(showDetails = value) }, + ) + + val ViewOptionsImageType = + AppChoicePreference( + title = R.string.image_type, + defaultValue = ViewOptionImageType.PRIMARY, + displayValues = R.array.image_types, + getter = { it.imageType }, + setter = { viewOptions, value -> + val aspectRatio = + when (value) { + ViewOptionImageType.PRIMARY -> AspectRatio.TALL + ViewOptionImageType.THUMB -> AspectRatio.WIDE + } + viewOptions.copy(imageType = value, aspectRatio = aspectRatio) + }, + indexToValue = { ViewOptionImageType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsReset = + AppClickablePreference( + title = R.string.reset, + ) + + val OPTIONS = + listOf( + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsDetailHeader, + ViewOptionsColumns, + ViewOptionsSpacing, + ViewOptionsContentScale, + ViewOptionsReset, + ) + } +} + +val ViewOptionsPoster = + ViewOptions( + columns = 6, + spacing = 16, + contentScale = PrefContentScale.FILL, + ) +val ViewOptionsWide = + ViewOptions( + columns = 4, + spacing = 24, + contentScale = PrefContentScale.CROP, + aspectRatio = AspectRatio.WIDE, + ) +val ViewOptionsSquare = + ViewOptions( + columns = 6, + spacing = 16, + contentScale = PrefContentScale.FILL, + aspectRatio = AspectRatio.SQUARE, + ) + +enum class ViewOptionImageType( + val imageType: ImageType, +) { + PRIMARY(ImageType.PRIMARY), + THUMB(ImageType.THUMB), +// BANNER(ImageType.BANNER), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt index 2efd20de..d71cea69 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -13,7 +13,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid -import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters +import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel @@ -48,7 +48,7 @@ fun CollectionFolderBoxSet( positionCallback = { columns, position -> showHeader = position < columns }, - params = CollectionFolderGridParameters.POSTER, + defaultViewOptions = ViewOptionsPoster, playEnabled = playEnabled, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index 2d5dab7d..6852abbc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -14,7 +14,8 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid -import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters +import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster +import com.github.damontecres.wholphin.ui.components.ViewOptionsWide import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import org.jellyfin.sdk.model.api.ItemSortBy @@ -34,12 +35,12 @@ fun CollectionFolderGeneric( preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { var showHeader by remember { mutableStateOf(true) } - val params = + val viewOptions = remember(usePosters) { if (usePosters) { - CollectionFolderGridParameters.POSTER + ViewOptionsPoster } else { - CollectionFolderGridParameters.WIDE + ViewOptionsWide } } CollectionFolderGrid( @@ -56,7 +57,7 @@ fun CollectionFolderGeneric( positionCallback = { columns, position -> showHeader = position < columns }, - params = params, + defaultViewOptions = viewOptions, playEnabled = playEnabled, filterOptions = filterOptions, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index e6185e45..16ffcad4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid @@ -170,6 +171,7 @@ fun CollectionFolderLiveTv( showHeader = position < columns }, playEnabled = false, + defaultViewOptions = ViewOptions(), ) } else { ErrorMessage("Invalid tab index $selectedTabIndex", null) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index cfbd6806..04bee238 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedMovie import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.logTab @@ -118,6 +119,7 @@ fun CollectionFolderMovie( showTitle = false, recursive = true, sortOptions = MovieSortOptions, + defaultViewOptions = ViewOptionsPoster, modifier = Modifier .padding(start = 16.dp) @@ -144,6 +146,7 @@ fun CollectionFolderMovie( showTitle = false, recursive = true, sortOptions = VideoSortOptions, + defaultViewOptions = ViewOptionsPoster, modifier = Modifier .padding(start = 16.dp) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index c630a85b..15e36ad3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -13,7 +13,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid -import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters +import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import java.util.UUID @@ -43,7 +43,7 @@ fun CollectionFolderPlaylist( positionCallback = { columns, position -> showHeader = position < columns }, - params = CollectionFolderGridParameters.SQUARE, + defaultViewOptions = ViewOptionsSquare, playEnabled = false, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt index c86088e3..0540c299 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt @@ -12,7 +12,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid -import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters +import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import java.util.UUID @@ -41,7 +41,7 @@ fun CollectionFolderRecordings( positionCallback = { columns, position -> showHeader = position < columns }, - params = CollectionFolderGridParameters.POSTER, + defaultViewOptions = ViewOptionsPoster, playEnabled = false, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index 75033bde..e19df84e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -31,6 +31,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedTvShow import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.SeriesSortOptions import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination @@ -118,6 +119,7 @@ fun CollectionFolderTv( showTitle = false, recursive = true, sortOptions = SeriesSortOptions, + defaultViewOptions = ViewOptionsPoster, modifier = Modifier .padding(start = 16.dp) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index a68ce42d..f334f530 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -29,9 +29,11 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid -import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster +import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare +import com.github.damontecres.wholphin.ui.components.ViewOptionsWide import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.SeriesSortOptions @@ -121,6 +123,7 @@ fun FavoritesPage( showTitle = false, recursive = true, sortOptions = MovieSortOptions, + defaultViewOptions = ViewOptionsPoster, modifier = Modifier .padding(start = 16.dp) @@ -147,6 +150,7 @@ fun FavoritesPage( showTitle = false, recursive = true, sortOptions = SeriesSortOptions, + defaultViewOptions = ViewOptionsPoster, modifier = Modifier .padding(start = 16.dp) @@ -173,7 +177,7 @@ fun FavoritesPage( showTitle = false, recursive = true, sortOptions = EpisodeSortOptions, - params = CollectionFolderGridParameters.WIDE, + defaultViewOptions = ViewOptionsWide, useSeriesForPrimary = false, modifier = Modifier @@ -201,7 +205,7 @@ fun FavoritesPage( showTitle = false, recursive = true, sortOptions = VideoSortOptions, - params = CollectionFolderGridParameters.WIDE, + defaultViewOptions = ViewOptionsWide, modifier = Modifier .padding(start = 16.dp) @@ -228,7 +232,7 @@ fun FavoritesPage( showTitle = false, recursive = true, sortOptions = VideoSortOptions, - params = CollectionFolderGridParameters.SQUARE, + defaultViewOptions = ViewOptionsSquare, modifier = Modifier .padding(start = 16.dp) @@ -260,6 +264,7 @@ fun FavoritesPage( showTitle = false, recursive = true, sortOptions = listOf(), + defaultViewOptions = ViewOptionsPoster, modifier = Modifier .padding(start = 16.dp) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index e315a396..7efc8952 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.SlimItemFields @@ -69,6 +70,7 @@ import com.github.damontecres.wholphin.util.RowLoadingState import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest @@ -185,11 +187,13 @@ fun PersonPage( person?.let { person -> var showOverviewDialog by remember { mutableStateOf(false) } val name = person.name ?: person.id.toString() + val imageUrlService = LocalImageUrlService.current + val imageUrl = remember { imageUrlService.getItemImageUrl(itemId = person.id, imageType = ImageType.PRIMARY) } PersonPageContent( preferences = preferences, name = name, overview = person.data.overview, - imageUrl = person.imageUrl, + imageUrl = imageUrl, birthdate = person.data.premiereDate?.toLocalDate(), deathdate = person.data.endDate?.toLocalDate(), birthPlace = person.data.productionLocations?.firstOrNull(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 09df1d8c..9d58c17d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -32,16 +32,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -53,12 +48,12 @@ import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation -import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.cards.ItemCardImage +import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup @@ -69,7 +64,6 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -224,35 +218,7 @@ fun PlaylistDetailsContent( Box( modifier = modifier, ) { - if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) { - val gradientColor = MaterialTheme.colorScheme.background - AsyncImage( - model = focusedItem.backdropImageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - alignment = Alignment.TopEnd, - modifier = - Modifier - .fillMaxHeight(.85f) - .alpha(.4f) - .drawWithContent { - drawContent() - drawRect( - Brush.verticalGradient( - colors = listOf(Color.Transparent, gradientColor), - startY = 500f, - ), - ) - drawRect( - Brush.horizontalGradient( - colors = listOf(gradientColor, Color.Transparent), - endX = 400f, - startX = 100f, - ), - ) - }, - ) - } + DelayedDetailsBackdropImage(focusedItem) Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = @@ -438,7 +404,7 @@ fun PlaylistItem( style = MaterialTheme.typography.labelLarge, ) ItemCardImage( - imageUrl = item?.imageUrl, + item = item, name = item?.name, showOverlay = true, favorite = item?.data?.userData?.isFavorite ?: false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index 034f6855..981ac82f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -271,7 +271,6 @@ fun EpisodeDetailsContent( var position by rememberInt(0) val focusRequesters = remember { List(1) { FocusRequester() } } val dto = ep.data - val backdropImageUrl = ep.backdropImageUrl val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val bringIntoViewRequester = remember { BringIntoViewRequester() } @@ -279,7 +278,7 @@ fun EpisodeDetailsContent( focusRequesters.getOrNull(position)?.tryRequestFocus() } Box(modifier = modifier) { - DetailsBackdropImage(backdropImageUrl) + DetailsBackdropImage(ep) LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 6e44b334..ec593aac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -371,7 +371,6 @@ fun MovieDetailsContent( var position by rememberInt(0) val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val dto = movie.data - val backdropImageUrl = movie.backdropImageUrl val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val bringIntoViewRequester = remember { BringIntoViewRequester() } @@ -379,7 +378,7 @@ fun MovieDetailsContent( focusRequesters.getOrNull(position)?.tryRequestFocus() } Box(modifier = modifier) { - DetailsBackdropImage(backdropImageUrl) + DetailsBackdropImage(movie) LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 7675d949..0c5f379c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -316,7 +316,7 @@ fun SeriesDetailsContent( Box( modifier = modifier, ) { - DetailsBackdropImage(series.backdropImageUrl) + DetailsBackdropImage(series) Column( modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 198e8c48..6a2f6058 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -183,7 +183,7 @@ fun SeriesOverviewContent( ?: episode?.data?.premiereDate?.let(::formatDateTime) BannerCard( name = episode?.name, - imageUrl = episode?.imageUrl, + item = episode, aspectRatio = episode ?.data diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 503c113b..dde9ed1d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -28,15 +28,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -45,24 +41,22 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.transitionFactory import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress +import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.MovieQuickDetails +import com.github.damontecres.wholphin.ui.components.SeriesQuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumnSaver @@ -79,7 +73,6 @@ import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType import java.util.UUID -import kotlin.time.Duration.Companion.milliseconds @Composable fun HomePage( @@ -242,46 +235,10 @@ fun HomePageContent( LaunchedEffect(position) { listState.animateScrollToItem(position.row) } - var backdropImageUrl by remember { mutableStateOf(null) } - LaunchedEffect(focusedItem) { - backdropImageUrl = null - delay(150) - backdropImageUrl = focusedItem?.backdropImageUrl - } Box(modifier = modifier) { - val gradientColor = MaterialTheme.colorScheme.background - AsyncImage( - model = - ImageRequest - .Builder(context) - .data(backdropImageUrl) - .transitionFactory(CrossFadeFactory(250.milliseconds)) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - alignment = Alignment.TopEnd, - modifier = - Modifier - .fillMaxHeight(.7f) - .fillMaxWidth(.7f) - .alpha(.75f) - .align(Alignment.TopEnd) - .drawWithContent { - drawContent() - drawRect( - Brush.verticalGradient( - colors = listOf(Color.Transparent, gradientColor), - startY = size.height * .33f, - ), - ) - drawRect( - Brush.horizontalGradient( - colors = listOf(gradientColor, Color.Transparent), - startX = 0f, - endX = size.width * .5f, - ), - ) - }, + DelayedDetailsBackdropImage( + item = focusedItem, + modifier = Modifier, ) Column(modifier = Modifier.fillMaxSize()) { @@ -382,7 +339,7 @@ fun HomePageContent( cardContent = { index, item, cardModifier, onClick, onLongClick -> BannerCard( name = item?.data?.seriesName ?: item?.name, - imageUrl = item?.imageUrl, + item = item, aspectRatio = AspectRatios.TALL, cornerText = item?.data?.indexNumber?.let { "E$it" } @@ -486,10 +443,10 @@ fun HomePageHeader( overflow = TextOverflow.Ellipsis, ) } - if (isEpisode) { - EpisodeQuickDetails(dto, Modifier) - } else { - MovieQuickDetails(dto, Modifier) + when (item.type) { + BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier) + BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier) + else -> MovieQuickDetails(dto, Modifier) } val overviewModifier = 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 af2d3796..3ddfe034 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 @@ -63,7 +63,7 @@ import androidx.tv.material3.DrawerValue import androidx.tv.material3.Icon import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.NavigationDrawer +import androidx.tv.material3.ModalNavigationDrawer import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerScope @@ -295,7 +295,8 @@ fun NavDrawer( } } - val drawerWidth by animateDpAsState(if (drawerState.isOpen) 260.dp else 40.dp) + val closedDrawerWidth = 40.dp + val drawerWidth by animateDpAsState(if (drawerState.isOpen) 260.dp else closedDrawerWidth) val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp) val drawerBackground by animateColorAsState( if (drawerState.isOpen) { @@ -329,7 +330,7 @@ fun NavDrawer( scrollToSelected() } - NavigationDrawer( + ModalNavigationDrawer( modifier = modifier, drawerState = drawerState, drawerContent = { @@ -518,7 +519,10 @@ fun NavDrawer( }, ) { Box( - modifier = Modifier.fillMaxSize(), + modifier = + Modifier + .padding(start = closedDrawerWidth) + .fillMaxSize(), ) { // Drawer content DestinationContent( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 13d891c9..1e19ce3d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.cards.ChapterCard import com.github.damontecres.wholphin.ui.cards.SeasonCard @@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.tryRequestFocus import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.TrickplayInfo import java.time.LocalTime @@ -381,14 +383,15 @@ fun PlaybackOverlay( } } } + val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO) AnimatedVisibility( - !showDebugInfo && item?.logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, + !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, modifier = Modifier .align(Alignment.TopStart), ) { AsyncImage( - model = item?.logoImageUrl, + model = logoImageUrl, contentDescription = "Logo", modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 8dc28e7d..5c291695 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage @@ -459,7 +460,7 @@ fun PlaybackPage( it.name, ).joinToString(" - "), description = it.data.overview, - imageUrl = it.imageUrl, + imageUrl = LocalImageUrlService.current.rememberImageUrl(it), aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: AspectRatios.WIDE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index b19d9390..d9ccbc9f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -57,12 +57,13 @@ import java.util.SortedSet @Suppress("UNCHECKED_CAST") @Composable fun ComposablePreference( - preference: AppPreference, + preference: AppPreference<*, T>, value: T?, onValueChange: (T) -> Unit, onNavigate: (Destination) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onClickPreference: (AppClickablePreference<*>) -> Unit = {}, ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -72,13 +73,6 @@ fun ComposablePreference( val title = stringResource(preference.title) - val onClick: () -> Unit = { - scope.launch(ExceptionHandler()) { - when (preference) { - else -> {} - } - } - } val onLongClick: () -> Unit = { scope.launch(ExceptionHandler()) { when (preference) { @@ -102,7 +96,7 @@ fun ComposablePreference( is AppClickablePreference -> ClickPreference( title = title, - onClick = onClick, + onClick = { onClickPreference.invoke(preference) }, onLongClick = onLongClick, summary = preference.summary(context, value), interactionSource = interactionSource, @@ -196,7 +190,7 @@ fun ComposablePreference( ) } - is AppMultiChoicePreference<*> -> { + is AppMultiChoicePreference<*, *> -> { val values = stringArrayResource(preference.displayValues).toSortedSet() val summary = preference.summary?.let { stringResource(it) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index 71dd09fc..ba3d54da 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -10,13 +10,13 @@ import kotlinx.serialization.Serializable */ data class PreferenceGroup( @param:StringRes val title: Int, - val preferences: List>, + val preferences: List>, val conditionalPreferences: List = listOf(), ) data class ConditionalPreferences( val condition: (AppPreferences) -> Boolean, - val preferences: List>, + val preferences: List>, ) /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index d4a988cc..c7334c1e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -219,7 +219,7 @@ fun PreferencesContent( .map { it.preferences } .flatten() groupPreferences.forEachIndexed { prefIndex, pref -> - pref as AppPreference + pref as AppPreference item { val interactionSource = remember { MutableInteractionSource() } val focused = interactionSource.collectIsFocusedAsState().value diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt index 4aefde8c..56c15f7f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt @@ -28,7 +28,7 @@ import com.github.damontecres.wholphin.ui.components.SliderBar @Composable fun SliderPreference( - preference: AppSliderPreference, + preference: AppSliderPreference<*>, title: String, summary: String?, value: Long, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt index bfc342cd..9daee6fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt @@ -13,6 +13,7 @@ import androidx.media3.ui.CaptionStyleCompat import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppClickablePreference +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppSliderPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.BackgroundStyle @@ -27,7 +28,7 @@ import timber.log.Timber object SubtitleSettings { val FontSize = - AppSliderPreference( + AppSliderPreference( title = R.string.font_size, defaultValue = 24, min = 8, @@ -58,7 +59,7 @@ object SubtitleSettings { ) val FontColor = - AppChoicePreference( + AppChoicePreference( title = R.string.font_color, defaultValue = Color.White, getter = { Color(it.interfacePreferences.subtitlesPreferences.fontColor) }, @@ -74,7 +75,7 @@ object SubtitleSettings { ) val FontBold = - AppSwitchPreference( + AppSwitchPreference( title = R.string.bold_font, defaultValue = false, getter = { it.interfacePreferences.subtitlesPreferences.fontBold }, @@ -83,7 +84,7 @@ object SubtitleSettings { }, ) val FontItalic = - AppSwitchPreference( + AppSwitchPreference( title = R.string.italic_font, defaultValue = false, getter = { it.interfacePreferences.subtitlesPreferences.fontItalic }, @@ -93,7 +94,7 @@ object SubtitleSettings { ) val FontOpacity = - AppSliderPreference( + AppSliderPreference( title = R.string.font_opacity, defaultValue = 100, min = 10, @@ -110,7 +111,7 @@ object SubtitleSettings { ) val EdgeStylePref = - AppChoicePreference( + AppChoicePreference( title = R.string.edge_style, defaultValue = EdgeStyle.EDGE_SOLID, @@ -124,7 +125,7 @@ object SubtitleSettings { ) val EdgeColor = - AppChoicePreference( + AppChoicePreference( title = R.string.edge_color, defaultValue = Color.Black, getter = { Color(it.interfacePreferences.subtitlesPreferences.edgeColor) }, @@ -140,7 +141,7 @@ object SubtitleSettings { ) val BackgroundColor = - AppChoicePreference( + AppChoicePreference( title = R.string.background_color, defaultValue = Color.Transparent, getter = { Color(it.interfacePreferences.subtitlesPreferences.backgroundColor) }, @@ -156,7 +157,7 @@ object SubtitleSettings { ) val BackgroundOpacity = - AppSliderPreference( + AppSliderPreference( title = R.string.background_opacity, defaultValue = 50, min = 10, @@ -173,7 +174,7 @@ object SubtitleSettings { ) val BackgroundStylePref = - AppChoicePreference( + AppChoicePreference( title = R.string.background_style, defaultValue = BackgroundStyle.BG_NONE, @@ -187,7 +188,7 @@ object SubtitleSettings { ) val Margin = - AppSliderPreference( + AppSliderPreference( title = R.string.subtitle_margin, defaultValue = 8, min = 0, @@ -204,7 +205,7 @@ object SubtitleSettings { ) val Reset = - AppClickablePreference( + AppClickablePreference( title = R.string.reset, getter = { }, setter = { prefs, _ -> prefs }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt index 1e912448..dfae73c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt @@ -82,23 +82,24 @@ private fun ThemeExample(theme: AppThemeColors) { modifier = Modifier.fillMaxWidth(), ) { WatchedIcon() - BannerCard( - name = "Card", - imageUrl = null, - onClick = { }, - onLongClick = {}, - playPercent = .5, - cardHeight = 64.dp, - ) - BannerCard( - name = "Card", - imageUrl = null, - onClick = { }, - onLongClick = {}, - playPercent = .5, - cardHeight = 64.dp, - interactionSource = source, - ) + // TODO +// BannerCard( +// name = "Card", +// imageUrl = null, +// onClick = { }, +// onLongClick = {}, +// playPercent = .5, +// cardHeight = 64.dp, +// ) +// BannerCard( +// name = "Card", +// imageUrl = null, +// onClick = { }, +// onLongClick = {}, +// playPercent = .5, +// cardHeight = 64.dp, +// interactionSource = source, +// ) SeasonCard( title = "Card", subtitle = "2025", diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index ad36a929..8fdae6dd 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -40,4 +40,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 275e256e..37a6a444 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -329,6 +329,12 @@ Margin Verbose logging Image disk cache size (MB) + View options + Columns + Spacing + Aspect Ratio + Show details + Image type Disabled @@ -404,4 +410,17 @@ MPV (Experimental) + + Poster (2:3) + 16:9 + 4:3 + Square (1:1) + + + + Primary + Thumb + + +