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
This commit is contained in:
damontecres 2025-12-04 12:20:50 -05:00 committed by GitHub
parent b98fb4a1d8
commit 384401a72c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1658 additions and 628 deletions

View file

@ -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')"
]
}
}

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -19,7 +20,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -28,19 +28,20 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository 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.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.DeviceProfileService 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.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.ui.CoilConfig 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.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -84,6 +85,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var deviceProfileService: DeviceProfileService lateinit var deviceProfileService: DeviceProfileService
@Inject
lateinit var imageUrlService: ImageUrlService
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -93,16 +97,6 @@ class MainActivity : AppCompatActivity() {
appUpgradeHandler.copySubfont(false) appUpgradeHandler.copySubfont(false)
} }
setContent { 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) val appPreferences by userPreferencesDataStore.data.collectAsState(null)
appPreferences?.let { appPreferences -> appPreferences?.let { appPreferences ->
CoilConfig( CoilConfig(
@ -121,81 +115,83 @@ class MainActivity : AppCompatActivity() {
LaunchedEffect(appPreferences.debugLogging) { LaunchedEffect(appPreferences.debugLogging) {
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
} }
WholphinTheme( CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
true, WholphinTheme(
appThemeColors = appPreferences.interfacePreferences.appThemeColors, true,
) { appThemeColors = appPreferences.interfacePreferences.appThemeColors,
Surface(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
shape = RectangleShape,
) { ) {
var isRestoringSession by remember { mutableStateOf(true) } Surface(
LaunchedEffect(Unit) { modifier =
try { Modifier
serverRepository.restoreSession( .fillMaxSize()
appPreferences.currentServerId?.toUUIDOrNull(), .background(MaterialTheme.colorScheme.background),
appPreferences.currentUserId?.toUUIDOrNull(), shape = RectangleShape,
) ) {
} catch (ex: Exception) { var isRestoringSession by remember { mutableStateOf(true) }
Timber.e(ex, "Exception restoring session") 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 = val preferences =
UserPreferences( UserPreferences(
appPreferences, appPreferences,
current?.userDto?.configuration ?: DefaultUserConfiguration, 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),
) )
}
} else { if (isRestoringSession) {
key(current) { Box(
val initialDestination = modifier = Modifier.size(200.dp),
if (current != null) { contentAlignment = Alignment.Center,
Destination.Home() ) {
} else { CircularProgressIndicator(
Destination.ServerList color = MaterialTheme.colorScheme.border,
} modifier = Modifier.align(Alignment.Center),
val backStack = rememberNavBackStack(initialDestination) )
navigationManager.backStack = backStack }
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { } else {
LaunchedEffect(Unit) { key(current) {
try { val initialDestination =
updateChecker.maybeShowUpdateToast(appPreferences.updateUrl) if (current != null) {
} catch (ex: Exception) { Destination.Home()
Timber.w(ex, "Failed to check for update") } 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) {
LaunchedEffect(current, preferences) { withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { deviceProfileService.getOrCreateDeviceProfile(
deviceProfileService.getOrCreateDeviceProfile( preferences.appPreferences.playbackPreferences,
preferences.appPreferences.playbackPreferences, current?.server?.serverVersion,
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(),
)
} }
} }
} }

View file

@ -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.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
@ -22,7 +23,7 @@ import java.util.UUID
@Database( @Database(
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class], entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class],
version = 8, version = 9,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(3, 4), AutoMigration(3, 4),
@ -30,6 +31,7 @@ import java.util.UUID
AutoMigration(5, 6), AutoMigration(5, 6),
AutoMigration(6, 7), AutoMigration(6, 7),
AutoMigration(7, 8), AutoMigration(7, 8),
AutoMigration(8, 9),
], ],
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -73,6 +75,18 @@ class Converters {
Timber.e(ex, "Error parsing filter") Timber.e(ex, "Error parsing filter")
GetItemsFilter() 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 { object Migrations {

View file

@ -13,7 +13,6 @@ sealed interface ExtrasItem {
val type: ExtraType val type: ExtraType
val destination: Destination val destination: Destination
val title: String? val title: String?
val imageUrl: String?
data class Group( data class Group(
override val parentId: UUID, override val parentId: UUID,
@ -24,7 +23,6 @@ sealed interface ExtrasItem {
Destination.ItemGrid(null, type.stringRes, items.map { it.id }) Destination.ItemGrid(null, type.stringRes, items.map { it.id })
override val title: String? = null override val title: String? = null
override val imageUrl: String? = items.random().imageUrl
} }
data class Single( data class Single(
@ -37,7 +35,6 @@ sealed interface ExtrasItem {
item = item, item = item,
) )
override val title: String? get() = item.title override val title: String? get() = item.title
override val imageUrl: String? get() = item.imageUrl
} }
} }

View file

@ -8,20 +8,15 @@ import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient 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.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import kotlin.time.Duration import kotlin.time.Duration
@Serializable @Serializable
data class BaseItem( data class BaseItem(
val data: BaseItemDto, val data: BaseItemDto,
val imageUrl: String?, val useSeriesForPrimary: Boolean,
val backdropImageUrl: String? = null,
val logoImageUrl: String? = null,
) { ) {
val id get() = data.id val id get() = data.id
@ -95,72 +90,15 @@ data class BaseItem(
} }
companion object { 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( fun from(
dto: BaseItemDto, dto: BaseItemDto,
api: ApiClient, api: ApiClient,
useSeriesForPrimary: Boolean = false, useSeriesForPrimary: Boolean = false,
): BaseItem { ): BaseItem =
val backdropImageUrl = BaseItem(
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(
dto, dto,
primaryImageUrl, useSeriesForPrimary,
backdropImageUrl,
logoImageUrl,
) )
}
} }
} }

View file

@ -7,6 +7,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Ignore import androidx.room.Ignore
import androidx.room.Index import androidx.room.Index
import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
@ -36,6 +37,7 @@ data class LibraryDisplayInfo(
val direction: SortOrder, val direction: SortOrder,
@ColumnInfo(defaultValue = "{}") @ColumnInfo(defaultValue = "{}")
val filter: GetItemsFilter, val filter: GetItemsFilter,
val viewOptions: ViewOptions?,
) { ) {
@Ignore @Transient @Ignore @Transient
val sortAndDirection = SortAndDirection(sort, direction) val sortAndDirection = SortAndDirection(sort, direction)

View file

@ -24,7 +24,7 @@ import kotlin.time.Duration.Companion.seconds
* *
* @param T The type of the preference value. * @param T The type of the preference value.
*/ */
sealed interface AppPreference<T> { sealed interface AppPreference<Pref, T> {
/** /**
* String resource ID for the title of the preference * String resource ID for the title of the preference
*/ */
@ -40,12 +40,12 @@ sealed interface AppPreference<T> {
* A function that gets the value from the [AppPreferences] object for UI purposes. This means * 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 * 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 * 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( fun summary(
context: Context, context: Context,
@ -56,7 +56,7 @@ sealed interface AppPreference<T> {
companion object { companion object {
val SkipForward = val SkipForward =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.skip_forward_preference, title = R.string.skip_forward_preference,
defaultValue = 30, defaultValue = 30,
min = 10, min = 10,
@ -85,7 +85,7 @@ sealed interface AppPreference<T> {
) )
val SkipBack = val SkipBack =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.skip_back_preference, title = R.string.skip_back_preference,
defaultValue = 10, defaultValue = 10,
min = 5, min = 5,
@ -114,7 +114,7 @@ sealed interface AppPreference<T> {
) )
// val GridJumpButtons = // val GridJumpButtons =
// AppSwitchPreference( // AppSwitchPreference<AppPreferences>(
// title = R.string.show_grid_jump_buttons, // title = R.string.show_grid_jump_buttons,
// defaultValue = true, // defaultValue = true,
// getter = { it.interfacePreferences.showGridJumpButtons }, // getter = { it.interfacePreferences.showGridJumpButtons },
@ -126,7 +126,7 @@ sealed interface AppPreference<T> {
// ) // )
// val ShowGridFooter = // val ShowGridFooter =
// AppSwitchPreference( // AppSwitchPreference<AppPreferences>(
// title = R.string.grid_position_footer, // title = R.string.grid_position_footer,
// defaultValue = true, // defaultValue = true,
// getter = { it.interfacePreferences.showPositionFooter }, // getter = { it.interfacePreferences.showPositionFooter },
@ -138,7 +138,7 @@ sealed interface AppPreference<T> {
// ) // )
val ControllerTimeout = val ControllerTimeout =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.hide_controller_timeout, title = R.string.hide_controller_timeout,
defaultValue = 5000, defaultValue = 5000,
min = 500, min = 500,
@ -159,7 +159,7 @@ sealed interface AppPreference<T> {
) )
val SeekBarSteps = val SeekBarSteps =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.seek_bar_steps, title = R.string.seek_bar_steps,
defaultValue = 16, defaultValue = 16,
min = 4, min = 4,
@ -173,7 +173,7 @@ sealed interface AppPreference<T> {
) )
val HomePageItems = val HomePageItems =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.max_homepage_items, title = R.string.max_homepage_items,
defaultValue = 25, defaultValue = 25,
min = 5, min = 5,
@ -187,7 +187,7 @@ sealed interface AppPreference<T> {
) )
val CombineContinueNext = val CombineContinueNext =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.combine_continue_next, title = R.string.combine_continue_next,
defaultValue = false, defaultValue = false,
getter = { it.homePagePreferences.combineContinueNext }, getter = { it.homePagePreferences.combineContinueNext },
@ -199,7 +199,7 @@ sealed interface AppPreference<T> {
) )
val RewatchNextUp = val RewatchNextUp =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.rewatch_next_up, title = R.string.rewatch_next_up,
defaultValue = false, defaultValue = false,
getter = { it.homePagePreferences.enableRewatchingNextUp }, getter = { it.homePagePreferences.enableRewatchingNextUp },
@ -211,7 +211,7 @@ sealed interface AppPreference<T> {
) )
val PlayThemeMusic = val PlayThemeMusic =
AppChoicePreference<ThemeSongVolume>( AppChoicePreference<AppPreferences, ThemeSongVolume>(
title = R.string.play_theme_music, title = R.string.play_theme_music,
defaultValue = ThemeSongVolume.MEDIUM, defaultValue = ThemeSongVolume.MEDIUM,
getter = { it.interfacePreferences.playThemeSongs }, getter = { it.interfacePreferences.playThemeSongs },
@ -224,7 +224,7 @@ sealed interface AppPreference<T> {
) )
val PlaybackDebugInfo = val PlaybackDebugInfo =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.playback_debug_info, title = R.string.playback_debug_info,
defaultValue = false, defaultValue = false,
getter = { it.playbackPreferences.showDebugInfo }, getter = { it.playbackPreferences.showDebugInfo },
@ -236,7 +236,7 @@ sealed interface AppPreference<T> {
) )
val AutoPlayNextUp = val AutoPlayNextUp =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.auto_play_next, title = R.string.auto_play_next,
defaultValue = true, defaultValue = true,
getter = { it.playbackPreferences.autoPlayNext }, getter = { it.playbackPreferences.autoPlayNext },
@ -248,7 +248,7 @@ sealed interface AppPreference<T> {
) )
val SkipBackOnResume = val SkipBackOnResume =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.skip_back_on_resume_preference, title = R.string.skip_back_on_resume_preference,
defaultValue = 0, defaultValue = 0,
min = 0, min = 0,
@ -270,7 +270,7 @@ sealed interface AppPreference<T> {
) )
val AutoPlayNextDelay = val AutoPlayNextDelay =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.auto_play_next_delay, title = R.string.auto_play_next_delay,
defaultValue = 15, defaultValue = 15,
min = 0, min = 0,
@ -296,7 +296,7 @@ sealed interface AppPreference<T> {
) )
val PassOutProtection = val PassOutProtection =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.pass_out_protection, title = R.string.pass_out_protection,
defaultValue = 2, defaultValue = 2,
min = 0, min = 0,
@ -341,7 +341,7 @@ sealed interface AppPreference<T> {
*(120..200 step 20).map { it * MEGA_BIT }.toTypedArray(), *(120..200 step 20).map { it * MEGA_BIT }.toTypedArray(),
) )
val MaxBitrate = val MaxBitrate =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.max_bitrate, title = R.string.max_bitrate,
defaultValue = bitrateValues.indexOf(DEFAULT_BITRATE).toLong(), defaultValue = bitrateValues.indexOf(DEFAULT_BITRATE).toLong(),
min = 0, min = 0,
@ -370,7 +370,7 @@ sealed interface AppPreference<T> {
) )
val Ac3Supported = val Ac3Supported =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.ac3_supported, title = R.string.ac3_supported,
defaultValue = true, defaultValue = true,
getter = { it.playbackPreferences.overrides.ac3Supported }, getter = { it.playbackPreferences.overrides.ac3Supported },
@ -381,7 +381,7 @@ sealed interface AppPreference<T> {
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val DownMixStereo = val DownMixStereo =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.downmix_stereo, title = R.string.downmix_stereo,
defaultValue = false, defaultValue = false,
getter = { it.playbackPreferences.overrides.downmixStereo }, getter = { it.playbackPreferences.overrides.downmixStereo },
@ -392,7 +392,7 @@ sealed interface AppPreference<T> {
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val DirectPlayAss = val DirectPlayAss =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.direct_play_ass, title = R.string.direct_play_ass,
defaultValue = true, defaultValue = true,
getter = { it.playbackPreferences.overrides.directPlayAss }, getter = { it.playbackPreferences.overrides.directPlayAss },
@ -403,7 +403,7 @@ sealed interface AppPreference<T> {
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val DirectPlayPgs = val DirectPlayPgs =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.direct_play_pgs, title = R.string.direct_play_pgs,
defaultValue = true, defaultValue = true,
getter = { it.playbackPreferences.overrides.directPlayPgs }, getter = { it.playbackPreferences.overrides.directPlayPgs },
@ -415,7 +415,7 @@ sealed interface AppPreference<T> {
) )
val RememberSelectedTab = val RememberSelectedTab =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.remember_selected_tab, title = R.string.remember_selected_tab,
defaultValue = false, defaultValue = false,
getter = { it.interfacePreferences.rememberSelectedTab }, getter = { it.interfacePreferences.rememberSelectedTab },
@ -427,7 +427,7 @@ sealed interface AppPreference<T> {
) )
val ThemeColors = val ThemeColors =
AppChoicePreference<AppThemeColors>( AppChoicePreference<AppPreferences, AppThemeColors>(
title = R.string.app_theme, title = R.string.app_theme,
defaultValue = AppThemeColors.PURPLE, defaultValue = AppThemeColors.PURPLE,
getter = { it.interfacePreferences.appThemeColors }, getter = { it.interfacePreferences.appThemeColors },
@ -440,21 +440,21 @@ sealed interface AppPreference<T> {
) )
val InstalledVersion = val InstalledVersion =
AppClickablePreference( AppClickablePreference<AppPreferences>(
title = R.string.installed_version, title = R.string.installed_version,
getter = { }, getter = { },
setter = { prefs, _ -> prefs }, setter = { prefs, _ -> prefs },
) )
val Update = val Update =
AppClickablePreference( AppClickablePreference<AppPreferences>(
title = R.string.check_for_updates, title = R.string.check_for_updates,
getter = { }, getter = { },
setter = { prefs, _ -> prefs }, setter = { prefs, _ -> prefs },
) )
val AutoCheckForUpdates = val AutoCheckForUpdates =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.auto_check_for_updates, title = R.string.auto_check_for_updates,
defaultValue = true, defaultValue = true,
getter = { it.autoCheckForUpdates }, getter = { it.autoCheckForUpdates },
@ -466,7 +466,7 @@ sealed interface AppPreference<T> {
) )
val UpdateUrl = val UpdateUrl =
AppStringPreference( AppStringPreference<AppPreferences>(
title = R.string.update_url, title = R.string.update_url,
defaultValue = "https://api.github.com/repos/damontecres/Wholphin/releases/latest", defaultValue = "https://api.github.com/repos/damontecres/Wholphin/releases/latest",
getter = { it.updateUrl }, getter = { it.updateUrl },
@ -477,19 +477,19 @@ sealed interface AppPreference<T> {
) )
val OssLicenseInfo = val OssLicenseInfo =
AppDestinationPreference( AppDestinationPreference<AppPreferences>(
title = R.string.license_info, title = R.string.license_info,
destination = Destination.License, destination = Destination.License,
) )
val AdvancedSettings = val AdvancedSettings =
AppDestinationPreference( AppDestinationPreference<AppPreferences>(
title = R.string.advanced_settings, title = R.string.advanced_settings,
destination = Destination.Settings(PreferenceScreenOption.ADVANCED), destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
) )
val SkipIntros = val SkipIntros =
AppChoicePreference<SkipSegmentBehavior>( AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
title = R.string.skip_intro_behavior, title = R.string.skip_intro_behavior,
defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, defaultValue = SkipSegmentBehavior.ASK_TO_SKIP,
getter = { it.playbackPreferences.skipIntros }, getter = { it.playbackPreferences.skipIntros },
@ -502,7 +502,7 @@ sealed interface AppPreference<T> {
) )
val SkipOutros = val SkipOutros =
AppChoicePreference<SkipSegmentBehavior>( AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
title = R.string.skip_outro_behavior, title = R.string.skip_outro_behavior,
defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, defaultValue = SkipSegmentBehavior.ASK_TO_SKIP,
getter = { it.playbackPreferences.skipOutros }, getter = { it.playbackPreferences.skipOutros },
@ -515,7 +515,7 @@ sealed interface AppPreference<T> {
) )
val SkipCommercials = val SkipCommercials =
AppChoicePreference<SkipSegmentBehavior>( AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
title = R.string.skip_commercials_behavior, title = R.string.skip_commercials_behavior,
defaultValue = SkipSegmentBehavior.ASK_TO_SKIP, defaultValue = SkipSegmentBehavior.ASK_TO_SKIP,
getter = { it.playbackPreferences.skipCommercials }, getter = { it.playbackPreferences.skipCommercials },
@ -528,7 +528,7 @@ sealed interface AppPreference<T> {
) )
val SkipPreviews = val SkipPreviews =
AppChoicePreference<SkipSegmentBehavior>( AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
title = R.string.skip_previews_behavior, title = R.string.skip_previews_behavior,
defaultValue = SkipSegmentBehavior.IGNORE, defaultValue = SkipSegmentBehavior.IGNORE,
getter = { it.playbackPreferences.skipPreviews }, getter = { it.playbackPreferences.skipPreviews },
@ -541,7 +541,7 @@ sealed interface AppPreference<T> {
) )
val SkipRecaps = val SkipRecaps =
AppChoicePreference<SkipSegmentBehavior>( AppChoicePreference<AppPreferences, SkipSegmentBehavior>(
title = R.string.skip_recap_behavior, title = R.string.skip_recap_behavior,
defaultValue = SkipSegmentBehavior.IGNORE, defaultValue = SkipSegmentBehavior.IGNORE,
getter = { it.playbackPreferences.skipRecaps }, getter = { it.playbackPreferences.skipRecaps },
@ -554,7 +554,7 @@ sealed interface AppPreference<T> {
) )
val GlobalContentScale = val GlobalContentScale =
AppChoicePreference<PrefContentScale>( AppChoicePreference<AppPreferences, PrefContentScale>(
title = R.string.global_content_scale, title = R.string.global_content_scale,
defaultValue = PrefContentScale.FIT, defaultValue = PrefContentScale.FIT,
getter = { it.playbackPreferences.globalContentScale }, getter = { it.playbackPreferences.globalContentScale },
@ -567,7 +567,7 @@ sealed interface AppPreference<T> {
) )
val FfmpegPreference = val FfmpegPreference =
AppChoicePreference<MediaExtensionStatus>( AppChoicePreference<AppPreferences, MediaExtensionStatus>(
title = R.string.ffmpeg_extension_pref, title = R.string.ffmpeg_extension_pref,
defaultValue = MediaExtensionStatus.MES_FALLBACK, defaultValue = MediaExtensionStatus.MES_FALLBACK,
getter = { it.playbackPreferences.overrides.mediaExtensionsEnabled }, getter = { it.playbackPreferences.overrides.mediaExtensionsEnabled },
@ -580,14 +580,14 @@ sealed interface AppPreference<T> {
) )
val ClearImageCache = val ClearImageCache =
AppClickablePreference( AppClickablePreference<AppPreferences>(
title = R.string.clear_image_cache, title = R.string.clear_image_cache,
getter = { }, getter = { },
setter = { prefs, _ -> prefs }, setter = { prefs, _ -> prefs },
) )
val UserPinnedNavDrawerItems = val UserPinnedNavDrawerItems =
AppClickablePreference( AppClickablePreference<AppPreferences>(
title = R.string.nav_drawer_pins, title = R.string.nav_drawer_pins,
summary = R.string.nav_drawer_pins_summary, summary = R.string.nav_drawer_pins_summary,
getter = { }, getter = { },
@ -595,7 +595,7 @@ sealed interface AppPreference<T> {
) )
val SendCrashReports = val SendCrashReports =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.send_crash_reports, title = R.string.send_crash_reports,
defaultValue = true, defaultValue = true,
getter = { getter = {
@ -615,7 +615,7 @@ sealed interface AppPreference<T> {
) )
val SendAppLogs = val SendAppLogs =
AppClickablePreference( AppClickablePreference<AppPreferences>(
title = R.string.send_app_logs, title = R.string.send_app_logs,
summary = R.string.send_app_logs_summary, summary = R.string.send_app_logs_summary,
getter = { }, getter = { },
@ -623,7 +623,7 @@ sealed interface AppPreference<T> {
) )
val NavDrawerSwitchOnFocus = val NavDrawerSwitchOnFocus =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.nav_drawer_switch_on_focus, title = R.string.nav_drawer_switch_on_focus,
defaultValue = true, defaultValue = true,
getter = { it.interfacePreferences.navDrawerSwitchOnFocus }, getter = { it.interfacePreferences.navDrawerSwitchOnFocus },
@ -635,7 +635,7 @@ sealed interface AppPreference<T> {
) )
val ShowNextUpTiming = val ShowNextUpTiming =
AppChoicePreference<ShowNextUpWhen>( AppChoicePreference<AppPreferences, ShowNextUpWhen>(
title = R.string.show_next_up_when, title = R.string.show_next_up_when,
defaultValue = ShowNextUpWhen.END_OF_PLAYBACK, defaultValue = ShowNextUpWhen.END_OF_PLAYBACK,
getter = { it.playbackPreferences.showNextUpWhen }, getter = { it.playbackPreferences.showNextUpWhen },
@ -648,7 +648,7 @@ sealed interface AppPreference<T> {
) )
val ShowClock = val ShowClock =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.show_clock, title = R.string.show_clock,
defaultValue = true, defaultValue = true,
getter = { it.interfacePreferences.showClock }, getter = { it.interfacePreferences.showClock },
@ -660,7 +660,7 @@ sealed interface AppPreference<T> {
) )
val OneClickPause = val OneClickPause =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.one_click_pause, title = R.string.one_click_pause,
defaultValue = false, defaultValue = false,
getter = { it.playbackPreferences.oneClickPause }, getter = { it.playbackPreferences.oneClickPause },
@ -672,13 +672,13 @@ sealed interface AppPreference<T> {
) )
val SubtitleStyle = val SubtitleStyle =
AppDestinationPreference( AppDestinationPreference<AppPreferences>(
title = R.string.subtitle_style, title = R.string.subtitle_style,
destination = Destination.Settings(PreferenceScreenOption.SUBTITLES), destination = Destination.Settings(PreferenceScreenOption.SUBTITLES),
) )
val PlayerBackendPref = val PlayerBackendPref =
AppChoicePreference<PlayerBackend>( AppChoicePreference<AppPreferences, PlayerBackend>(
title = R.string.player_backend, title = R.string.player_backend,
defaultValue = PlayerBackend.EXO_PLAYER, defaultValue = PlayerBackend.EXO_PLAYER,
getter = { it.playbackPreferences.playerBackend }, getter = { it.playbackPreferences.playerBackend },
@ -691,7 +691,7 @@ sealed interface AppPreference<T> {
) )
val MpvHardwareDecoding = val MpvHardwareDecoding =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.mpv_hardware_decoding, title = R.string.mpv_hardware_decoding,
defaultValue = true, defaultValue = true,
getter = { it.playbackPreferences.mpvOptions.enableHardwareDecoding }, getter = { it.playbackPreferences.mpvOptions.enableHardwareDecoding },
@ -702,7 +702,7 @@ sealed interface AppPreference<T> {
) )
val DebugLogging = val DebugLogging =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.verbose_logging, title = R.string.verbose_logging,
defaultValue = false, defaultValue = false,
getter = { DebugLogTree.INSTANCE.enabled }, getter = { DebugLogTree.INSTANCE.enabled },
@ -715,7 +715,7 @@ sealed interface AppPreference<T> {
) )
val ImageDiskCacheSize = val ImageDiskCacheSize =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.image_cache_size, title = R.string.image_cache_size,
defaultValue = 200, defaultValue = 200,
min = 25, min = 25,
@ -884,16 +884,16 @@ val advancedPreferences =
) )
} }
data class AppSwitchPreference( data class AppSwitchPreference<Pref>(
@get:StringRes override val title: Int, @get:StringRes override val title: Int,
override val defaultValue: Boolean, override val defaultValue: Boolean,
override val getter: (prefs: AppPreferences) -> Boolean, override val getter: (prefs: Pref) -> Boolean,
override val setter: (prefs: AppPreferences, value: Boolean) -> AppPreferences, override val setter: (prefs: Pref, value: Boolean) -> Pref,
val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid }, val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid },
@param:StringRes val summary: Int? = null, @param:StringRes val summary: Int? = null,
@param:StringRes val summaryOn: Int? = null, @param:StringRes val summaryOn: Int? = null,
@param:StringRes val summaryOff: Int? = null, @param:StringRes val summaryOff: Int? = null,
) : AppPreference<Boolean> { ) : AppPreference<Pref, Boolean> {
override fun summary( override fun summary(
context: Context, context: Context,
value: Boolean?, value: Boolean?,
@ -905,70 +905,70 @@ data class AppSwitchPreference(
} }
} }
open class AppStringPreference( open class AppStringPreference<Pref>(
@param:StringRes override val title: Int, @param:StringRes override val title: Int,
override val defaultValue: String, override val defaultValue: String,
override val getter: (AppPreferences) -> String, override val getter: (Pref) -> String,
override val setter: (AppPreferences, String) -> AppPreferences, override val setter: (Pref, String) -> Pref,
@param:StringRes val summary: Int?, @param:StringRes val summary: Int?,
) : AppPreference<String> { ) : AppPreference<Pref, String> {
override fun summary( override fun summary(
context: Context, context: Context,
value: String?, value: String?,
): String? = summary?.let { context.getString(it) } ?: value ): String? = summary?.let { context.getString(it) } ?: value
} }
data class AppChoicePreference<T>( data class AppChoicePreference<Pref, T>(
@param:StringRes override val title: Int, @param:StringRes override val title: Int,
override val defaultValue: T, override val defaultValue: T,
@param:ArrayRes val displayValues: Int, @param:ArrayRes val displayValues: Int,
val indexToValue: (index: Int) -> T, val indexToValue: (index: Int) -> T,
val valueToIndex: (T) -> Int, val valueToIndex: (T) -> Int,
override val getter: (prefs: AppPreferences) -> T, override val getter: (prefs: Pref) -> T,
override val setter: (prefs: AppPreferences, value: T) -> AppPreferences, override val setter: (prefs: Pref, value: T) -> Pref,
@param:StringRes val summary: Int? = null, @param:StringRes val summary: Int? = null,
) : AppPreference<T> ) : AppPreference<Pref, T>
data class AppMultiChoicePreference<T>( data class AppMultiChoicePreference<Pref, T>(
@param:StringRes override val title: Int, @param:StringRes override val title: Int,
override val defaultValue: List<T>, override val defaultValue: List<T>,
val allValues: List<T>, val allValues: List<T>,
@param:ArrayRes val displayValues: Int, @param:ArrayRes val displayValues: Int,
override val getter: (prefs: AppPreferences) -> List<T>, override val getter: (prefs: Pref) -> List<T>,
override val setter: (prefs: AppPreferences, value: List<T>) -> AppPreferences, override val setter: (prefs: Pref, value: List<T>) -> Pref,
@param:StringRes val summary: Int? = null, @param:StringRes val summary: Int? = null,
val toSharedPrefs: (T) -> String, val toSharedPrefs: (T) -> String,
val fromSharedPrefs: (String) -> T?, val fromSharedPrefs: (String) -> T?,
) : AppPreference<List<T>> ) : AppPreference<Pref, List<T>>
data class AppClickablePreference( data class AppClickablePreference<Pref>(
@param:StringRes override val title: Int, @param:StringRes override val title: Int,
override val defaultValue: Unit = Unit, override val defaultValue: Unit = Unit,
override val getter: (prefs: AppPreferences) -> Unit = { }, override val getter: (prefs: Pref) -> Unit = { },
override val setter: (prefs: AppPreferences, value: Unit) -> AppPreferences = { prefs, _ -> prefs }, override val setter: (prefs: Pref, value: Unit) -> Pref = { prefs, _ -> prefs },
@param:StringRes val summary: Int? = null, @param:StringRes val summary: Int? = null,
) : AppPreference<Unit> { ) : AppPreference<Pref, Unit> {
override fun summary( override fun summary(
context: Context, context: Context,
value: Unit?, value: Unit?,
): String? = summary?.let { context.getString(it) } ): String? = summary?.let { context.getString(it) }
} }
data class AppDestinationPreference( data class AppDestinationPreference<Pref>(
@param:StringRes override val title: Int, @param:StringRes override val title: Int,
override val defaultValue: Unit = Unit, override val defaultValue: Unit = Unit,
override val getter: (prefs: AppPreferences) -> Unit = { }, override val getter: (prefs: Pref) -> Unit = { },
override val setter: (prefs: AppPreferences, value: Unit) -> AppPreferences = { prefs, _ -> prefs }, override val setter: (prefs: Pref, value: Unit) -> Pref = { prefs, _ -> prefs },
@param:StringRes val summary: Int? = null, @param:StringRes val summary: Int? = null,
val destination: Destination, val destination: Destination,
) : AppPreference<Unit> { ) : AppPreference<Pref, Unit> {
override fun summary( override fun summary(
context: Context, context: Context,
value: Unit?, value: Unit?,
): String? = summary?.let { context.getString(it) } ): String? = summary?.let { context.getString(it) }
} }
class AppSliderPreference( class AppSliderPreference<Pref>(
@param:StringRes override val title: Int, @param:StringRes override val title: Int,
override val defaultValue: Long, override val defaultValue: Long,
/** /**
@ -980,11 +980,11 @@ class AppSliderPreference(
*/ */
val max: Long = 100, val max: Long = 100,
val interval: Int = 1, val interval: Int = 1,
override val getter: (prefs: AppPreferences) -> Long, override val getter: (prefs: Pref) -> Long,
override val setter: (prefs: AppPreferences, value: Long) -> AppPreferences, override val setter: (prefs: Pref, value: Long) -> Pref,
@param:StringRes val summary: Int? = null, @param:StringRes val summary: Int? = null,
val summarizer: ((Long?) -> String?)? = null, val summarizer: ((Long?) -> String?)? = null,
) : AppPreference<Long> { ) : AppPreference<Pref, Long> {
override fun summary( override fun summary(
context: Context, context: Context,
value: Long?, value: Long?,

View file

@ -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
}
}

View file

@ -44,6 +44,7 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -308,7 +309,7 @@ fun logCoilError(
url: String?, url: String?,
errorResult: ErrorResult, 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) Timber.w("Error loading image: %s for %s", errorResult.throwable.localizedMessage, url)
} else { } else {
Timber.e(errorResult.throwable, "Error loading image: %s", url) Timber.e(errorResult.throwable, "Error loading image: %s", url)
@ -401,3 +402,9 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
api: ApiClient, api: ApiClient,
useSeriesForPrimary: Boolean, useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } ) = 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) }
}

View file

@ -2,18 +2,23 @@ package com.github.damontecres.wholphin.ui
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.services.ImageUrlService
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
// This file is for constants used for the UI // This file is for constants used for the UI
val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900)) val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900))
val LocalImageUrlService =
staticCompositionLocalOf<ImageUrlService> { throw IllegalStateException("LocalImageUrlService not set") }
/** /**
* Colors not associated with the theme * Colors not associated with the theme
*/ */
@ -81,6 +86,15 @@ object AspectRatios {
const val SQUARE = 1f 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( @Preview(
device = "spec:parent=tv_1080p", device = "spec:parent=tv_1080p",
backgroundColor = 0xFF383535, backgroundColor = 0xFF383535,

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -33,11 +34,14 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R 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.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome 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.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 * 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 @Composable
fun BannerCard( fun BannerCard(
name: String?, name: String?,
imageUrl: String?, item: BaseItem?,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -57,6 +61,29 @@ fun BannerCard(
aspectRatio: Float = AspectRatios.WIDE, aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource? = null, 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) } var imageError by remember { mutableStateOf(false) }
Card( Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight), modifier = modifier.size(cardHeight * aspectRatio, cardHeight),

View file

@ -90,7 +90,7 @@ fun EpisodeCard(
.fillMaxSize(), .fillMaxSize(),
) { ) {
ItemCardImage( ItemCardImage(
imageUrl = item?.imageUrl, item = item,
name = item?.name, name = item?.name,
showOverlay = false, showOverlay = false,
favorite = dto?.userData?.isFavorite ?: false, favorite = dto?.userData?.isFavorite ?: false,

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.cards package com.github.damontecres.wholphin.ui.cards
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources 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.data.pluralRes
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import org.jellyfin.sdk.model.api.ImageType
@Composable @Composable
fun ExtrasRow( fun ExtrasRow(
@ -27,6 +30,17 @@ fun ExtrasRow(
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
cardContent = { index, item, mod, onClick, onLongClick -> 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( SeasonCard(
title = title =
when (item) { when (item) {
@ -49,7 +63,7 @@ fun ExtrasRow(
showImageOverlay = true, showImageOverlay = true,
imageHeight = Cards.height2x3 * .75f, imageHeight = Cards.height2x3 * .75f,
imageWidth = Dp.Unspecified, imageWidth = Dp.Unspecified,
imageUrl = item?.imageUrl, imageUrl = imageUrl,
isFavorite = false, isFavorite = false,
isPlayed = false, isPlayed = false,
unplayedItemCount = -1, unplayedItemCount = -1,

View file

@ -27,6 +27,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -42,6 +43,7 @@ fun GridCard(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
imageAspectRatio: Float = AspectRatios.TALL, imageAspectRatio: Float = AspectRatios.TALL,
imageContentScale: ContentScale = ContentScale.Fit, imageContentScale: ContentScale = ContentScale.Fit,
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
) { ) {
val dto = item?.data val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
@ -78,7 +80,8 @@ fun GridCard(
), ),
) { ) {
ItemCardImage( ItemCardImage(
imageUrl = item?.imageUrl, item = item,
imageType = imageType.imageType,
name = item?.name, name = item?.name,
// showOverlay = !focusedAfterDelay, // showOverlay = !focusedAfterDelay,
showOverlay = true, showOverlay = true,

View file

@ -1,12 +1,10 @@
package com.github.damontecres.wholphin.ui.cards 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.ColorFilter
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onLayoutRectChanged
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R 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.AppColors
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.logCoilError 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 @Composable
fun ItemCardImage( fun ItemCardImage(
@ -54,8 +111,10 @@ fun ItemCardImage(
useFallbackText: Boolean = true, useFallbackText: Boolean = true,
contentScale: ContentScale = ContentScale.Fit, contentScale: ContentScale = ContentScale.Fit,
) { ) {
var imageError by remember { mutableStateOf(false) } var imageError by remember(imageUrl) { mutableStateOf(false) }
Box(modifier = modifier) { Box(
modifier = modifier,
) {
if (!imageError && imageUrl.isNotNullOrBlank()) { if (!imageError && imageUrl.isNotNullOrBlank()) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
@ -72,104 +131,130 @@ fun ItemCardImage(
.align(Alignment.TopCenter), .align(Alignment.TopCenter),
) )
} else { } else {
// TODO options for overriding fallback ItemCardImageFallback(
Box( name = name,
modifier = useFallbackText = useFallbackText,
Modifier 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),
)
}
}
} }
AnimatedVisibility( if (showOverlay) {
visible = showOverlay, ItemCardImageOverlay(
enter = fadeIn(), favorite = favorite,
exit = fadeOut(), watched = watched,
) { unwatchedCount = unwatchedCount,
Box(modifier = Modifier.fillMaxSize()) { watchedPercent = watchedPercent,
if (favorite) { modifier = Modifier,
Text( )
modifier = }
Modifier }
.align(Alignment.TopStart) }
.padding(8.dp),
color = colorResource(android.R.color.holo_red_light), @Composable
text = stringResource(R.string.fa_heart), fun BoxScope.ItemCardImageFallback(
fontSize = 20.sp, name: String?,
fontFamily = FontAwesome, useFallbackText: Boolean,
) modifier: Modifier = Modifier,
} ) {
Row( // TODO options for overriding fallback
horizontalArrangement = Arrangement.spacedBy(4.dp), Box(
verticalAlignment = Alignment.CenterVertically, modifier =
modifier = modifier
Modifier .background(MaterialTheme.colorScheme.surfaceVariant)
.padding(4.dp) .fillMaxSize()
.align(Alignment.TopEnd), .align(Alignment.TopCenter),
) { ) {
if (watched && (watchedPercent == null || watchedPercent <= 0.0 || watchedPercent >= 100.0)) { if (useFallbackText && name.isNotNullOrBlank()) {
WatchedIcon(Modifier.size(24.dp)) Text(
} text = name,
if (unwatchedCount > 0) { color = MaterialTheme.colorScheme.onSurfaceVariant,
Box( fontSize = 14.sp,
modifier = modifier =
Modifier Modifier
.background( .padding(8.dp)
AppColors.TransparentBlack50, .align(Alignment.Center),
shape = RoundedCornerShape(25), )
), } else {
) { Image(
Text( painter = painterResource(id = R.drawable.video_solid),
text = unwatchedCount.toString(), contentDescription = null,
color = MaterialTheme.colorScheme.onSurface, contentScale = ContentScale.Fit,
style = MaterialTheme.typography.bodyMedium, colorFilter =
// fontSize = 16.sp, ColorFilter.tint(
modifier = Modifier.padding(4.dp), MaterialTheme.colorScheme.onSurfaceVariant,
) BlendMode.SrcIn,
} ),
} modifier =
} Modifier
.fillMaxSize(.4f)
watchedPercent?.let { percent -> .align(Alignment.Center),
Box( )
modifier = }
Modifier }
.align(Alignment.BottomStart) }
.background(
MaterialTheme.colorScheme.tertiary, @Composable
).clip(RectangleShape) fun ItemCardImageOverlay(
.height(Cards.playedPercentHeight) favorite: Boolean,
.fillMaxWidth((percent / 100.0).toFloat()), 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()),
)
} }
} }
} }

View file

@ -111,7 +111,7 @@ fun BannerItemRow(
cardContent = { index, item, modifier, onClick, onLongClick -> cardContent = { index, item, modifier, onClick, onLongClick ->
BannerCard( BannerCard(
name = title, name = title,
imageUrl = item?.imageUrl, item = item,
aspectRatio = aspectRatio =
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat()
?: AspectRatios.WIDE, ?: AspectRatios.WIDE,

View file

@ -19,6 +19,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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 androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.ImageType
/** /**
* A Card for a TV Show Season, but can generically show most items * A Card for a TV Show Season, but can generically show most items
@ -44,24 +47,57 @@ fun SeasonCard(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false, showImageOverlay: Boolean = false,
aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL, aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL,
) = SeasonCard( ) {
title = item?.title, val imageUrlService = LocalImageUrlService.current
subtitle = item?.subtitle, val density = LocalDensity.current
name = item?.name, val imageUrl =
imageUrl = item?.imageUrl, remember(item, imageHeight, imageWidth) {
isFavorite = item?.data?.userData?.isFavorite ?: false, if (item != null) {
isPlayed = item?.data?.userData?.played ?: false, val fillHeight =
unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, if (imageHeight != Dp.Unspecified) {
playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, with(density) {
onClick = onClick, imageHeight.roundToPx()
onLongClick = onLongClick, }
modifier = modifier, } else {
imageHeight = imageHeight, null
imageWidth = imageWidth, }
interactionSource = interactionSource, val fillWidth =
showImageOverlay = showImageOverlay, if (imageWidth != Dp.Unspecified) {
aspectRatio = aspectRatio, 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 * A Card for a TV Show Season, but can generically show most items

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome 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.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.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
@ -125,6 +130,7 @@ class CollectionFolderViewModel
val pager = MutableLiveData<List<BaseItem?>>(listOf()) val pager = MutableLiveData<List<BaseItem?>>(listOf())
val sortAndDirection = MutableLiveData<SortAndDirection>() val sortAndDirection = MutableLiveData<SortAndDirection>()
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter()) val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
val viewOptions = MutableLiveData<ViewOptions>()
private var useSeriesForPrimary: Boolean = true private var useSeriesForPrimary: Boolean = true
@ -134,6 +140,7 @@ class CollectionFolderViewModel
recursive: Boolean, recursive: Boolean,
filter: GetItemsFilter, filter: GetItemsFilter,
useSeriesForPrimary: Boolean, useSeriesForPrimary: Boolean,
defaultViewOptions: ViewOptions,
): Job = ): Job =
viewModelScope.launch( viewModelScope.launch(
LoadingExceptionHandler( LoadingExceptionHandler(
@ -151,6 +158,9 @@ class CollectionFolderViewModel
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId) libraryDisplayInfoDao.getItem(user, itemId)
} }
this@CollectionFolderViewModel.viewOptions.setValueOnMain(
libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
)
val sortAndDirection = val sortAndDirection =
libraryDisplayInfo?.sortAndDirection libraryDisplayInfo?.sortAndDirection
@ -167,24 +177,40 @@ class CollectionFolderViewModel
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
} }
fun onFilterChange( private fun saveLibraryDisplayInfo(
newFilter: GetItemsFilter, newFilter: GetItemsFilter = this.filter.value!!,
recursive: Boolean, newSort: SortAndDirection = this.sortAndDirection.value!!,
viewOptions: ViewOptions? = this.viewOptions.value,
) { ) {
Timber.v("onFilterChange: filter=%s", newFilter)
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser.value?.let { user ->
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val libraryDisplayInfo = val libraryDisplayInfo =
LibraryDisplayInfo( LibraryDisplayInfo(
userId = user.rowId, userId = user.rowId,
itemId = itemId, itemId = itemId,
sort = sortAndDirection.value!!.sort, sort = newSort.sort,
direction = sortAndDirection.value!!.direction, direction = newSort.direction,
filter = newFilter, filter = newFilter,
viewOptions = viewOptions,
) )
libraryDisplayInfoDao.saveItem(libraryDisplayInfo) 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) loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary)
} }
@ -199,19 +225,7 @@ class CollectionFolderViewModel
recursive, recursive,
filter, filter,
) )
serverRepository.currentUser.value?.let { user -> saveLibraryDisplayInfo(filter, sortAndDirection)
viewModelScope.launch(Dispatchers.IO) {
val libraryDisplayInfo =
LibraryDisplayInfo(
userId = user.rowId,
itemId = itemId,
sort = sortAndDirection.sort,
direction = sortAndDirection.direction,
filter = filter,
)
libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
}
}
loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary) loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary)
} }
@ -454,11 +468,11 @@ fun CollectionFolderGrid(
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
playEnabled: Boolean, playEnabled: Boolean,
defaultViewOptions: ViewOptions,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialSortAndDirection: SortAndDirection? = null, initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
params: CollectionFolderGridParameters = CollectionFolderGridParameters(),
useSeriesForPrimary: Boolean = true, useSeriesForPrimary: Boolean = true,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
) = CollectionFolderGrid( ) = CollectionFolderGrid(
@ -469,11 +483,11 @@ fun CollectionFolderGrid(
onClickItem, onClickItem,
sortOptions, sortOptions,
playEnabled, playEnabled,
modifier, defaultViewOptions = defaultViewOptions,
modifier = modifier,
initialSortAndDirection = initialSortAndDirection, initialSortAndDirection = initialSortAndDirection,
showTitle = showTitle, showTitle = showTitle,
positionCallback = positionCallback, positionCallback = positionCallback,
params = params,
useSeriesForPrimary = useSeriesForPrimary, useSeriesForPrimary = useSeriesForPrimary,
filterOptions = filterOptions, filterOptions = filterOptions,
) )
@ -487,13 +501,13 @@ fun CollectionFolderGrid(
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
playEnabled: Boolean, playEnabled: Boolean,
defaultViewOptions: ViewOptions,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(), viewModel: CollectionFolderViewModel = hiltViewModel(),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
initialSortAndDirection: SortAndDirection? = null, initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
params: CollectionFolderGridParameters = CollectionFolderGridParameters(),
useSeriesForPrimary: Boolean = true, useSeriesForPrimary: Boolean = true,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
) { ) {
@ -505,6 +519,7 @@ fun CollectionFolderGrid(
recursive, recursive,
initialFilter, initialFilter,
useSeriesForPrimary, useSeriesForPrimary,
defaultViewOptions,
) )
} }
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
@ -513,6 +528,7 @@ fun CollectionFolderGrid(
val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val pager by viewModel.pager.observeAsState() val pager by viewModel.pager.observeAsState()
val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions)
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) } var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
@ -551,7 +567,9 @@ fun CollectionFolderGrid(
sortOptions = sortOptions, sortOptions = sortOptions,
positionCallback = positionCallback, positionCallback = positionCallback,
letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
params = params, viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions,
onSaveViewOptions = { viewModel.saveViewOptions(it) },
playEnabled = playEnabled, playEnabled = playEnabled,
onClickPlay = { shuffle -> onClickPlay = { shuffle ->
itemId.toUUIDOrNull()?.let { itemId.toUUIDOrNull()?.let {
@ -656,117 +674,174 @@ fun CollectionFolderGridContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
params: CollectionFolderGridParameters = CollectionFolderGridParameters(),
currentFilter: GetItemsFilter = GetItemsFilter(), currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List<ItemFilterBy<*>> = listOf(), filterOptions: List<ItemFilterBy<*>> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {}, onFilterChange: (GetItemsFilter) -> Unit = {},
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>, getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
defaultViewOptions: ViewOptions,
viewOptions: ViewOptions,
onSaveViewOptions: (ViewOptions) -> Unit,
) { ) {
val context = LocalContext.current
val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection)
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
var showViewOptions by rememberSaveable { mutableStateOf(false) }
var viewOptions by remember { mutableStateOf(viewOptions) }
val gridFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
Column( var backdropImageUrl by remember { mutableStateOf<String?>(null) }
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier, var position by rememberInt(0)
) { val focusedItem = pager.getOrNull(position)
AnimatedVisibility(
showHeader, Box(modifier = modifier) {
enter = slideInVertically() + fadeIn(), if (viewOptions.showDetails) {
exit = slideOutVertically() + fadeOut(), DelayedDetailsBackdropImage(
item = focusedItem,
modifier = Modifier,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.fillMaxSize(),
) { ) {
Column( AnimatedVisibility(
verticalArrangement = Arrangement.spacedBy(8.dp), showHeader,
modifier = Modifier.fillMaxWidth(), enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) { ) {
if (showTitle) { Column(
Text( verticalArrangement = Arrangement.spacedBy(8.dp),
text = title, modifier = Modifier.fillMaxWidth(),
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()) { if (showTitle) {
Row( Text(
horizontalArrangement = Arrangement.spacedBy(8.dp), text = title,
verticalAlignment = Alignment.CenterVertically, style = MaterialTheme.typography.displayMedium,
modifier = Modifier, color = MaterialTheme.colorScheme.onBackground,
) { textAlign = TextAlign.Center,
if (sortOptions.isNotEmpty()) { maxLines = 1,
SortByButton( overflow = TextOverflow.Ellipsis,
sortOptions = sortOptions, modifier = Modifier.fillMaxWidth(),
current = sortAndDirection, )
onSortChange = onSortChange, }
modifier = Modifier, val endPadding =
) 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
} Row(
if (filterOptions.isNotEmpty()) { horizontalArrangement = Arrangement.SpaceBetween,
FilterByButton( verticalAlignment = Alignment.CenterVertically,
filterOptions = filterOptions, modifier =
current = currentFilter, Modifier
onFilterChange = onFilterChange, .padding(start = 16.dp, end = endPadding)
getPossibleValues = getPossibleFilterValues, .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, modifier = Modifier,
) )
} }
} }
} if (playEnabled) {
if (playEnabled) { Row(
Row( horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically, modifier = Modifier,
modifier = Modifier, ) {
) { ExpandablePlayButton(
ExpandablePlayButton( title = R.string.play,
title = R.string.play, resume = Duration.ZERO,
resume = Duration.ZERO, icon = Icons.Default.PlayArrow,
icon = Icons.Default.PlayArrow, onClick = { onClickPlay.invoke(false) },
onClick = { onClickPlay.invoke(false) }, )
) ExpandableFaButton(
ExpandableFaButton( title = R.string.shuffle,
title = R.string.shuffle, iconStringRes = R.string.fa_shuffle,
iconStringRes = R.string.fa_shuffle, onClick = { onClickPlay.invoke(true) },
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,
)
} }
} }

View file

@ -2,7 +2,13 @@ package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage 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 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 @Composable
fun BoxScope.DetailsBackdropImage( 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<String?>(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,
),
)
},
)
}

View file

@ -76,3 +76,44 @@ fun EpisodeQuickDetails(
modifier = modifier, 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,
)
}

View file

@ -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<ViewOptions, Any>
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<ViewOptions>(
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<ViewOptions>(
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<ViewOptions, PrefContentScale>(
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<ViewOptions, AspectRatio>(
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<ViewOptions>(
title = R.string.show_details,
defaultValue = false,
getter = { it.showDetails },
setter = { vo, value -> vo.copy(showDetails = value) },
)
val ViewOptionsImageType =
AppChoicePreference<ViewOptions, ViewOptionImageType>(
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<ViewOptions>(
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),
}

View file

@ -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.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid 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.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
@ -48,7 +48,7 @@ fun CollectionFolderBoxSet(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
params = CollectionFolderGridParameters.POSTER, defaultViewOptions = ViewOptionsPoster,
playEnabled = playEnabled, playEnabled = playEnabled,
) )
} }

View file

@ -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.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid 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.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
@ -34,12 +35,12 @@ fun CollectionFolderGeneric(
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }
val params = val viewOptions =
remember(usePosters) { remember(usePosters) {
if (usePosters) { if (usePosters) {
CollectionFolderGridParameters.POSTER ViewOptionsPoster
} else { } else {
CollectionFolderGridParameters.WIDE ViewOptionsWide
} }
} }
CollectionFolderGrid( CollectionFolderGrid(
@ -56,7 +57,7 @@ fun CollectionFolderGeneric(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
params = params, defaultViewOptions = viewOptions,
playEnabled = playEnabled, playEnabled = playEnabled,
filterOptions = filterOptions, filterOptions = filterOptions,
) )

View file

@ -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.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.TabRow 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.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule
import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid
@ -170,6 +171,7 @@ fun CollectionFolderLiveTv(
showHeader = position < columns showHeader = position < columns
}, },
playEnabled = false, playEnabled = false,
defaultViewOptions = ViewOptions(),
) )
} else { } else {
ErrorMessage("Invalid tab index $selectedTabIndex", null) ErrorMessage("Invalid tab index $selectedTabIndex", null)

View file

@ -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.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedMovie import com.github.damontecres.wholphin.ui.components.RecommendedMovie
import com.github.damontecres.wholphin.ui.components.TabRow 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.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
@ -118,6 +119,7 @@ fun CollectionFolderMovie(
showTitle = false, showTitle = false,
recursive = true, recursive = true,
sortOptions = MovieSortOptions, sortOptions = MovieSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
@ -144,6 +146,7 @@ fun CollectionFolderMovie(
showTitle = false, showTitle = false,
recursive = true, recursive = true,
sortOptions = VideoSortOptions, sortOptions = VideoSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)

View file

@ -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.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid 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.data.PlaylistSortOptions
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import java.util.UUID import java.util.UUID
@ -43,7 +43,7 @@ fun CollectionFolderPlaylist(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
params = CollectionFolderGridParameters.SQUARE, defaultViewOptions = ViewOptionsSquare,
playEnabled = false, playEnabled = false,
) )
} }

View file

@ -12,7 +12,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid 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.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import java.util.UUID import java.util.UUID
@ -41,7 +41,7 @@ fun CollectionFolderRecordings(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
params = CollectionFolderGridParameters.POSTER, defaultViewOptions = ViewOptionsPoster,
playEnabled = false, playEnabled = false,
) )
} }

View file

@ -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.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow import com.github.damontecres.wholphin.ui.components.RecommendedTvShow
import com.github.damontecres.wholphin.ui.components.TabRow 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.data.SeriesSortOptions
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -118,6 +119,7 @@ fun CollectionFolderTv(
showTitle = false, showTitle = false,
recursive = true, recursive = true,
sortOptions = SeriesSortOptions, sortOptions = SeriesSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)

View file

@ -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.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.TabRow 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.EpisodeSortOptions
import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
@ -121,6 +123,7 @@ fun FavoritesPage(
showTitle = false, showTitle = false,
recursive = true, recursive = true,
sortOptions = MovieSortOptions, sortOptions = MovieSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
@ -147,6 +150,7 @@ fun FavoritesPage(
showTitle = false, showTitle = false,
recursive = true, recursive = true,
sortOptions = SeriesSortOptions, sortOptions = SeriesSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
@ -173,7 +177,7 @@ fun FavoritesPage(
showTitle = false, showTitle = false,
recursive = true, recursive = true,
sortOptions = EpisodeSortOptions, sortOptions = EpisodeSortOptions,
params = CollectionFolderGridParameters.WIDE, defaultViewOptions = ViewOptionsWide,
useSeriesForPrimary = false, useSeriesForPrimary = false,
modifier = modifier =
Modifier Modifier
@ -201,7 +205,7 @@ fun FavoritesPage(
showTitle = false, showTitle = false,
recursive = true, recursive = true,
sortOptions = VideoSortOptions, sortOptions = VideoSortOptions,
params = CollectionFolderGridParameters.WIDE, defaultViewOptions = ViewOptionsWide,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
@ -228,7 +232,7 @@ fun FavoritesPage(
showTitle = false, showTitle = false,
recursive = true, recursive = true,
sortOptions = VideoSortOptions, sortOptions = VideoSortOptions,
params = CollectionFolderGridParameters.SQUARE, defaultViewOptions = ViewOptionsSquare,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
@ -260,6 +264,7 @@ fun FavoritesPage(
showTitle = false, showTitle = false,
recursive = true, recursive = true,
sortOptions = listOf(), sortOptions = listOf(),
defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)

View file

@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager 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.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.SlimItemFields 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 dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind 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.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
@ -185,11 +187,13 @@ fun PersonPage(
person?.let { person -> person?.let { person ->
var showOverviewDialog by remember { mutableStateOf(false) } var showOverviewDialog by remember { mutableStateOf(false) }
val name = person.name ?: person.id.toString() val name = person.name ?: person.id.toString()
val imageUrlService = LocalImageUrlService.current
val imageUrl = remember { imageUrlService.getItemImageUrl(itemId = person.id, imageType = ImageType.PRIMARY) }
PersonPageContent( PersonPageContent(
preferences = preferences, preferences = preferences,
name = name, name = name,
overview = person.data.overview, overview = person.data.overview,
imageUrl = person.imageUrl, imageUrl = imageUrl,
birthdate = person.data.premiereDate?.toLocalDate(), birthdate = person.data.premiereDate?.toLocalDate(),
deathdate = person.data.endDate?.toLocalDate(), deathdate = person.data.endDate?.toLocalDate(),
birthPlace = person.data.productionLocations?.firstOrNull(), birthPlace = person.data.productionLocations?.firstOrNull(),

View file

@ -32,16 +32,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign 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.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.cards.ItemCardImage 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.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup 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.components.OverviewText
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.ifElse 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.nav.Destination
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -224,35 +218,7 @@ fun PlaylistDetailsContent(
Box( Box(
modifier = modifier, modifier = modifier,
) { ) {
if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) { DelayedDetailsBackdropImage(focusedItem)
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,
),
)
},
)
}
Column( Column(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier =
@ -438,7 +404,7 @@ fun PlaylistItem(
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
) )
ItemCardImage( ItemCardImage(
imageUrl = item?.imageUrl, item = item,
name = item?.name, name = item?.name,
showOverlay = true, showOverlay = true,
favorite = item?.data?.userData?.isFavorite ?: false, favorite = item?.data?.userData?.isFavorite ?: false,

View file

@ -271,7 +271,6 @@ fun EpisodeDetailsContent(
var position by rememberInt(0) var position by rememberInt(0)
val focusRequesters = remember { List(1) { FocusRequester() } } val focusRequesters = remember { List(1) { FocusRequester() } }
val dto = ep.data val dto = ep.data
val backdropImageUrl = ep.backdropImageUrl
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
@ -279,7 +278,7 @@ fun EpisodeDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus() focusRequesters.getOrNull(position)?.tryRequestFocus()
} }
Box(modifier = modifier) { Box(modifier = modifier) {
DetailsBackdropImage(backdropImageUrl) DetailsBackdropImage(ep)
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),

View file

@ -371,7 +371,6 @@ fun MovieDetailsContent(
var position by rememberInt(0) var position by rememberInt(0)
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val dto = movie.data val dto = movie.data
val backdropImageUrl = movie.backdropImageUrl
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
@ -379,7 +378,7 @@ fun MovieDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus() focusRequesters.getOrNull(position)?.tryRequestFocus()
} }
Box(modifier = modifier) { Box(modifier = modifier) {
DetailsBackdropImage(backdropImageUrl) DetailsBackdropImage(movie)
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),

View file

@ -316,7 +316,7 @@ fun SeriesDetailsContent(
Box( Box(
modifier = modifier, modifier = modifier,
) { ) {
DetailsBackdropImage(series.backdropImageUrl) DetailsBackdropImage(series)
Column( Column(
modifier = modifier =

View file

@ -183,7 +183,7 @@ fun SeriesOverviewContent(
?: episode?.data?.premiereDate?.let(::formatDateTime) ?: episode?.data?.premiereDate?.let(::formatDateTime)
BannerCard( BannerCard(
name = episode?.name, name = episode?.name,
imageUrl = episode?.imageUrl, item = episode,
aspectRatio = aspectRatio =
episode episode
?.data ?.data

View file

@ -28,15 +28,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards 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.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.MovieQuickDetails 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.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.RowColumnSaver 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.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun HomePage( fun HomePage(
@ -242,46 +235,10 @@ fun HomePageContent(
LaunchedEffect(position) { LaunchedEffect(position) {
listState.animateScrollToItem(position.row) listState.animateScrollToItem(position.row)
} }
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(focusedItem) {
backdropImageUrl = null
delay(150)
backdropImageUrl = focusedItem?.backdropImageUrl
}
Box(modifier = modifier) { Box(modifier = modifier) {
val gradientColor = MaterialTheme.colorScheme.background DelayedDetailsBackdropImage(
AsyncImage( item = focusedItem,
model = modifier = Modifier,
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,
),
)
},
) )
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
@ -382,7 +339,7 @@ fun HomePageContent(
cardContent = { index, item, cardModifier, onClick, onLongClick -> cardContent = { index, item, cardModifier, onClick, onLongClick ->
BannerCard( BannerCard(
name = item?.data?.seriesName ?: item?.name, name = item?.data?.seriesName ?: item?.name,
imageUrl = item?.imageUrl, item = item,
aspectRatio = AspectRatios.TALL, aspectRatio = AspectRatios.TALL,
cornerText = cornerText =
item?.data?.indexNumber?.let { "E$it" } item?.data?.indexNumber?.let { "E$it" }
@ -486,10 +443,10 @@ fun HomePageHeader(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
if (isEpisode) { when (item.type) {
EpisodeQuickDetails(dto, Modifier) BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier)
} else { BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier)
MovieQuickDetails(dto, Modifier) else -> MovieQuickDetails(dto, Modifier)
} }
val overviewModifier = val overviewModifier =
Modifier Modifier

View file

@ -63,7 +63,7 @@ import androidx.tv.material3.DrawerValue
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.NavigationDrawer import androidx.tv.material3.ModalNavigationDrawer
import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerItemDefaults
import androidx.tv.material3.NavigationDrawerScope 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 drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp)
val drawerBackground by animateColorAsState( val drawerBackground by animateColorAsState(
if (drawerState.isOpen) { if (drawerState.isOpen) {
@ -329,7 +330,7 @@ fun NavDrawer(
scrollToSelected() scrollToSelected()
} }
NavigationDrawer( ModalNavigationDrawer(
modifier = modifier, modifier = modifier,
drawerState = drawerState, drawerState = drawerState,
drawerContent = { drawerContent = {
@ -518,7 +519,10 @@ fun NavDrawer(
}, },
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier =
Modifier
.padding(start = closedDrawerWidth)
.fillMaxSize(),
) { ) {
// Drawer content // Drawer content
DestinationContent( DestinationContent(

View file

@ -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.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios 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.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ChapterCard import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard 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 com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
import java.time.LocalTime import java.time.LocalTime
@ -381,14 +383,15 @@ fun PlaybackOverlay(
} }
} }
} }
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
AnimatedVisibility( AnimatedVisibility(
!showDebugInfo && item?.logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible, !showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
modifier = modifier =
Modifier Modifier
.align(Alignment.TopStart), .align(Alignment.TopStart),
) { ) {
AsyncImage( AsyncImage(
model = item?.logoImageUrl, model = logoImageUrl,
contentDescription = "Logo", contentDescription = "Logo",
modifier = modifier =
Modifier Modifier

View file

@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.AspectRatios 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.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
@ -459,7 +460,7 @@ fun PlaybackPage(
it.name, it.name,
).joinToString(" - "), ).joinToString(" - "),
description = it.data.overview, description = it.data.overview,
imageUrl = it.imageUrl, imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
aspectRatio = aspectRatio =
it.data.primaryImageAspectRatio?.toFloat() it.data.primaryImageAspectRatio?.toFloat()
?: AspectRatios.WIDE, ?: AspectRatios.WIDE,

View file

@ -57,12 +57,13 @@ import java.util.SortedSet
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@Composable @Composable
fun <T> ComposablePreference( fun <T> ComposablePreference(
preference: AppPreference<T>, preference: AppPreference<*, T>,
value: T?, value: T?,
onValueChange: (T) -> Unit, onValueChange: (T) -> Unit,
onNavigate: (Destination) -> Unit, onNavigate: (Destination) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onClickPreference: (AppClickablePreference<*>) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -72,13 +73,6 @@ fun <T> ComposablePreference(
val title = stringResource(preference.title) val title = stringResource(preference.title)
val onClick: () -> Unit = {
scope.launch(ExceptionHandler()) {
when (preference) {
else -> {}
}
}
}
val onLongClick: () -> Unit = { val onLongClick: () -> Unit = {
scope.launch(ExceptionHandler()) { scope.launch(ExceptionHandler()) {
when (preference) { when (preference) {
@ -102,7 +96,7 @@ fun <T> ComposablePreference(
is AppClickablePreference -> is AppClickablePreference ->
ClickPreference( ClickPreference(
title = title, title = title,
onClick = onClick, onClick = { onClickPreference.invoke(preference) },
onLongClick = onLongClick, onLongClick = onLongClick,
summary = preference.summary(context, value), summary = preference.summary(context, value),
interactionSource = interactionSource, interactionSource = interactionSource,
@ -196,7 +190,7 @@ fun <T> ComposablePreference(
) )
} }
is AppMultiChoicePreference<*> -> { is AppMultiChoicePreference<*, *> -> {
val values = stringArrayResource(preference.displayValues).toSortedSet() val values = stringArrayResource(preference.displayValues).toSortedSet()
val summary = val summary =
preference.summary?.let { stringResource(it) } preference.summary?.let { stringResource(it) }

View file

@ -10,13 +10,13 @@ import kotlinx.serialization.Serializable
*/ */
data class PreferenceGroup( data class PreferenceGroup(
@param:StringRes val title: Int, @param:StringRes val title: Int,
val preferences: List<AppPreference<out Any?>>, val preferences: List<AppPreference<AppPreferences, out Any?>>,
val conditionalPreferences: List<ConditionalPreferences> = listOf(), val conditionalPreferences: List<ConditionalPreferences> = listOf(),
) )
data class ConditionalPreferences( data class ConditionalPreferences(
val condition: (AppPreferences) -> Boolean, val condition: (AppPreferences) -> Boolean,
val preferences: List<AppPreference<out Any?>>, val preferences: List<AppPreference<AppPreferences, out Any?>>,
) )
/** /**

View file

@ -219,7 +219,7 @@ fun PreferencesContent(
.map { it.preferences } .map { it.preferences }
.flatten() .flatten()
groupPreferences.forEachIndexed { prefIndex, pref -> groupPreferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<Any> pref as AppPreference<AppPreferences, Any>
item { item {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value val focused = interactionSource.collectIsFocusedAsState().value

View file

@ -28,7 +28,7 @@ import com.github.damontecres.wholphin.ui.components.SliderBar
@Composable @Composable
fun SliderPreference( fun SliderPreference(
preference: AppSliderPreference, preference: AppSliderPreference<*>,
title: String, title: String,
summary: String?, summary: String?,
value: Long, value: Long,

View file

@ -13,6 +13,7 @@ import androidx.media3.ui.CaptionStyleCompat
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppChoicePreference
import com.github.damontecres.wholphin.preferences.AppClickablePreference 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.AppSliderPreference
import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.preferences.BackgroundStyle import com.github.damontecres.wholphin.preferences.BackgroundStyle
@ -27,7 +28,7 @@ import timber.log.Timber
object SubtitleSettings { object SubtitleSettings {
val FontSize = val FontSize =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.font_size, title = R.string.font_size,
defaultValue = 24, defaultValue = 24,
min = 8, min = 8,
@ -58,7 +59,7 @@ object SubtitleSettings {
) )
val FontColor = val FontColor =
AppChoicePreference<Color>( AppChoicePreference<AppPreferences, Color>(
title = R.string.font_color, title = R.string.font_color,
defaultValue = Color.White, defaultValue = Color.White,
getter = { Color(it.interfacePreferences.subtitlesPreferences.fontColor) }, getter = { Color(it.interfacePreferences.subtitlesPreferences.fontColor) },
@ -74,7 +75,7 @@ object SubtitleSettings {
) )
val FontBold = val FontBold =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.bold_font, title = R.string.bold_font,
defaultValue = false, defaultValue = false,
getter = { it.interfacePreferences.subtitlesPreferences.fontBold }, getter = { it.interfacePreferences.subtitlesPreferences.fontBold },
@ -83,7 +84,7 @@ object SubtitleSettings {
}, },
) )
val FontItalic = val FontItalic =
AppSwitchPreference( AppSwitchPreference<AppPreferences>(
title = R.string.italic_font, title = R.string.italic_font,
defaultValue = false, defaultValue = false,
getter = { it.interfacePreferences.subtitlesPreferences.fontItalic }, getter = { it.interfacePreferences.subtitlesPreferences.fontItalic },
@ -93,7 +94,7 @@ object SubtitleSettings {
) )
val FontOpacity = val FontOpacity =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.font_opacity, title = R.string.font_opacity,
defaultValue = 100, defaultValue = 100,
min = 10, min = 10,
@ -110,7 +111,7 @@ object SubtitleSettings {
) )
val EdgeStylePref = val EdgeStylePref =
AppChoicePreference<EdgeStyle>( AppChoicePreference<AppPreferences, EdgeStyle>(
title = title =
R.string.edge_style, R.string.edge_style,
defaultValue = EdgeStyle.EDGE_SOLID, defaultValue = EdgeStyle.EDGE_SOLID,
@ -124,7 +125,7 @@ object SubtitleSettings {
) )
val EdgeColor = val EdgeColor =
AppChoicePreference<Color>( AppChoicePreference<AppPreferences, Color>(
title = R.string.edge_color, title = R.string.edge_color,
defaultValue = Color.Black, defaultValue = Color.Black,
getter = { Color(it.interfacePreferences.subtitlesPreferences.edgeColor) }, getter = { Color(it.interfacePreferences.subtitlesPreferences.edgeColor) },
@ -140,7 +141,7 @@ object SubtitleSettings {
) )
val BackgroundColor = val BackgroundColor =
AppChoicePreference<Color>( AppChoicePreference<AppPreferences, Color>(
title = R.string.background_color, title = R.string.background_color,
defaultValue = Color.Transparent, defaultValue = Color.Transparent,
getter = { Color(it.interfacePreferences.subtitlesPreferences.backgroundColor) }, getter = { Color(it.interfacePreferences.subtitlesPreferences.backgroundColor) },
@ -156,7 +157,7 @@ object SubtitleSettings {
) )
val BackgroundOpacity = val BackgroundOpacity =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.background_opacity, title = R.string.background_opacity,
defaultValue = 50, defaultValue = 50,
min = 10, min = 10,
@ -173,7 +174,7 @@ object SubtitleSettings {
) )
val BackgroundStylePref = val BackgroundStylePref =
AppChoicePreference<BackgroundStyle>( AppChoicePreference<AppPreferences, BackgroundStyle>(
title = title =
R.string.background_style, R.string.background_style,
defaultValue = BackgroundStyle.BG_NONE, defaultValue = BackgroundStyle.BG_NONE,
@ -187,7 +188,7 @@ object SubtitleSettings {
) )
val Margin = val Margin =
AppSliderPreference( AppSliderPreference<AppPreferences>(
title = R.string.subtitle_margin, title = R.string.subtitle_margin,
defaultValue = 8, defaultValue = 8,
min = 0, min = 0,
@ -204,7 +205,7 @@ object SubtitleSettings {
) )
val Reset = val Reset =
AppClickablePreference( AppClickablePreference<AppPreferences>(
title = R.string.reset, title = R.string.reset,
getter = { }, getter = { },
setter = { prefs, _ -> prefs }, setter = { prefs, _ -> prefs },

View file

@ -82,23 +82,24 @@ private fun ThemeExample(theme: AppThemeColors) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
WatchedIcon() WatchedIcon()
BannerCard( // TODO
name = "Card", // BannerCard(
imageUrl = null, // name = "Card",
onClick = { }, // imageUrl = null,
onLongClick = {}, // onClick = { },
playPercent = .5, // onLongClick = {},
cardHeight = 64.dp, // playPercent = .5,
) // cardHeight = 64.dp,
BannerCard( // )
name = "Card", // BannerCard(
imageUrl = null, // name = "Card",
onClick = { }, // imageUrl = null,
onLongClick = {}, // onClick = { },
playPercent = .5, // onLongClick = {},
cardHeight = 64.dp, // playPercent = .5,
interactionSource = source, // cardHeight = 64.dp,
) // interactionSource = source,
// )
SeasonCard( SeasonCard(
title = "Card", title = "Card",
subtitle = "2025", subtitle = "2025",

View file

@ -40,4 +40,5 @@
<string name="fa_ellipsis" translatable="false">&#xf141;</string> <string name="fa_ellipsis" translatable="false">&#xf141;</string>
<string name="fa_ellipsis_vertical" translatable="false">&#xf142;</string> <string name="fa_ellipsis_vertical" translatable="false">&#xf142;</string>
<string name="fa_filter" translatable="false">&#xf0b0;</string> <string name="fa_filter" translatable="false">&#xf0b0;</string>
<string name="fa_sliders" translatable="false">&#xf1de;</string>
</resources> </resources>

View file

@ -329,6 +329,12 @@
<string name="subtitle_margin">Margin</string> <string name="subtitle_margin">Margin</string>
<string name="verbose_logging">Verbose logging</string> <string name="verbose_logging">Verbose logging</string>
<string name="image_cache_size">Image disk cache size (MB)</string> <string name="image_cache_size">Image disk cache size (MB)</string>
<string name="view_options">View options</string>
<string name="columns">Columns</string>
<string name="spacing">Spacing</string>
<string name="aspect_ratio">Aspect Ratio</string>
<string name="show_details">Show details</string>
<string name="image_type">Image type</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>
@ -404,4 +410,17 @@
<item>MPV (Experimental)</item> <item>MPV (Experimental)</item>
</string-array> </string-array>
<string-array name="aspect_ratios">
<item>Poster (2:3)</item>
<item>16:9</item>
<item>4:3</item>
<item>Square (1:1)</item>
</string-array>
<string-array name="image_types">
<item>Primary</item>
<item>Thumb</item>
<!-- <item>Banner</item>-->
</string-array>
</resources> </resources>