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

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

View file

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

View file

@ -8,20 +8,15 @@ import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import kotlin.time.Duration
@Serializable
data class BaseItem(
val data: BaseItemDto,
val imageUrl: String?,
val backdropImageUrl: String? = null,
val logoImageUrl: String? = null,
val useSeriesForPrimary: Boolean,
) {
val id get() = data.id
@ -95,72 +90,15 @@ data class BaseItem(
}
companion object {
var primaryMaxWidth: Int? = null
set(value) {
Timber.v("primaryMaxWidth=$value")
field = value
}
var primaryMaxHeight: Int? = null
set(value) {
Timber.v("primaryMaxHeight=$value")
field = value
}
fun from(
dto: BaseItemDto,
api: ApiClient,
useSeriesForPrimary: Boolean = false,
): BaseItem {
val backdropImageUrl =
if (dto.type == BaseItemKind.EPISODE) {
val seriesId = dto.seriesId
if (seriesId != null) {
api.imageApi.getItemImageUrl(seriesId, ImageType.BACKDROP)
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP)
}
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP)
}
val primaryImageUrl =
if (useSeriesForPrimary && dto.type == BaseItemKind.EPISODE && dto.seriesId != null) {
api.imageApi.getItemImageUrl(
dto.seriesId!!,
ImageType.PRIMARY,
maxHeight = primaryMaxHeight,
maxWidth = primaryMaxWidth,
quality = 96,
)
} else if (dto.imageTags == null || dto.imageTags!![ImageType.PRIMARY] == null) {
// TODO is this a bad assumption?
null
} else {
api.imageApi.getItemImageUrl(
dto.id,
ImageType.PRIMARY,
maxHeight = primaryMaxHeight,
maxWidth = primaryMaxWidth,
quality = 96,
)
}
val logoImageUrl =
if (dto.type == BaseItemKind.EPISODE || dto.type == BaseItemKind.SEASON) {
val seriesId = dto.seriesId
if (seriesId != null) {
api.imageApi.getItemImageUrl(seriesId, ImageType.LOGO)
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.LOGO)
}
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.LOGO)
}
return BaseItem(
): BaseItem =
BaseItem(
dto,
primaryImageUrl,
backdropImageUrl,
logoImageUrl,
useSeriesForPrimary,
)
}
}
}

View file

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

View file

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

View file

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

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -33,11 +34,14 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.ImageType
/**
* Displays an image as a card. If no image is available, the name will be shown instead
@ -45,7 +49,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@Composable
fun BannerCard(
name: String?,
imageUrl: String?,
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
@ -57,6 +61,29 @@ fun BannerCard(
aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource? = null,
) {
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
val imageUrl =
remember(item, cardHeight) {
if (item != null) {
val fillHeight =
if (cardHeight != Dp.Unspecified) {
with(density) {
cardHeight.roundToPx()
}
} else {
null
}
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
fillWidth = null,
fillHeight = fillHeight,
)
} else {
null
}
}
var imageError by remember { mutableStateOf(false) }
Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),

View file

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

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
@ -11,6 +12,8 @@ import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.pluralRes
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun ExtrasRow(
@ -27,6 +30,17 @@ fun ExtrasRow(
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
cardContent = { index, item, mod, onClick, onLongClick ->
val imageUrlService = LocalImageUrlService.current
val imageUrl =
remember {
val item =
when (item) {
is ExtrasItem.Group -> item.items.random()
is ExtrasItem.Single -> item.item
null -> null
}
imageUrlService.getItemImageUrl(item, ImageType.PRIMARY)
}
SeasonCard(
title =
when (item) {
@ -49,7 +63,7 @@ fun ExtrasRow(
showImageOverlay = true,
imageHeight = Cards.height2x3 * .75f,
imageWidth = Dp.Unspecified,
imageUrl = item?.imageUrl,
imageUrl = imageUrl,
isFavorite = false,
isPlayed = false,
unplayedItemCount = -1,

View file

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

View file

@ -1,12 +1,10 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -26,20 +24,79 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onLayoutRectChanged
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.logCoilError
import org.jellyfin.sdk.model.api.ImageType
/**
* Display an image for an item with optional overlay data
*
* This will fetch the image using fillWidth/fillHeight based on the layout size
*/
@Composable
fun ItemCardImage(
item: BaseItem?,
name: String?,
showOverlay: Boolean,
favorite: Boolean,
watched: Boolean,
unwatchedCount: Int,
watchedPercent: Double?,
modifier: Modifier = Modifier,
imageType: ImageType = ImageType.PRIMARY,
useFallbackText: Boolean = true,
contentScale: ContentScale = ContentScale.Fit,
) {
val imageUrlService = LocalImageUrlService.current
var size by remember { mutableStateOf(IntSize.Zero) }
val imageUrl =
remember(size, item) {
if (size != IntSize.Zero && item != null) {
imageUrlService.getItemImageUrl(
item,
imageType,
fillWidth = size.width,
fillHeight = size.height,
)
} else {
null
}
}
ItemCardImage(
imageUrl = imageUrl,
name = name,
showOverlay = showOverlay,
favorite = favorite,
watched = watched,
unwatchedCount = unwatchedCount,
watchedPercent = watchedPercent,
modifier =
modifier.onLayoutRectChanged(
throttleMillis = 100,
debounceMillis = 25,
) {
size = IntSize(width = it.width, height = it.height)
},
useFallbackText = useFallbackText,
contentScale = contentScale,
)
}
@Composable
fun ItemCardImage(
@ -54,8 +111,10 @@ fun ItemCardImage(
useFallbackText: Boolean = true,
contentScale: ContentScale = ContentScale.Fit,
) {
var imageError by remember { mutableStateOf(false) }
Box(modifier = modifier) {
var imageError by remember(imageUrl) { mutableStateOf(false) }
Box(
modifier = modifier,
) {
if (!imageError && imageUrl.isNotNullOrBlank()) {
AsyncImage(
model = imageUrl,
@ -72,104 +131,130 @@ fun ItemCardImage(
.align(Alignment.TopCenter),
)
} else {
// TODO options for overriding fallback
Box(
modifier =
Modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.fillMaxSize()
.align(Alignment.TopCenter),
) {
if (useFallbackText && name.isNotNullOrBlank()) {
Text(
text = name,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
modifier =
Modifier
.padding(8.dp)
.align(Alignment.Center),
)
} else {
Image(
painter = painterResource(id = R.drawable.video_solid),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter =
ColorFilter.tint(
MaterialTheme.colorScheme.onSurfaceVariant,
BlendMode.SrcIn,
),
modifier =
Modifier
.fillMaxSize(.4f)
.align(Alignment.Center),
)
}
}
ItemCardImageFallback(
name = name,
useFallbackText = useFallbackText,
modifier = Modifier,
)
}
AnimatedVisibility(
visible = showOverlay,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(modifier = Modifier.fillMaxSize()) {
if (favorite) {
Text(
modifier =
Modifier
.align(Alignment.TopStart)
.padding(8.dp),
color = colorResource(android.R.color.holo_red_light),
text = stringResource(R.string.fa_heart),
fontSize = 20.sp,
fontFamily = FontAwesome,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(4.dp)
.align(Alignment.TopEnd),
) {
if (watched && (watchedPercent == null || watchedPercent <= 0.0 || watchedPercent >= 100.0)) {
WatchedIcon(Modifier.size(24.dp))
}
if (unwatchedCount > 0) {
Box(
modifier =
Modifier
.background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(25),
),
) {
Text(
text = unwatchedCount.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
// fontSize = 16.sp,
modifier = Modifier.padding(4.dp),
)
}
}
}
watchedPercent?.let { percent ->
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.background(
MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape)
.height(Cards.playedPercentHeight)
.fillMaxWidth((percent / 100.0).toFloat()),
)
}
}
if (showOverlay) {
ItemCardImageOverlay(
favorite = favorite,
watched = watched,
unwatchedCount = unwatchedCount,
watchedPercent = watchedPercent,
modifier = Modifier,
)
}
}
}
@Composable
fun BoxScope.ItemCardImageFallback(
name: String?,
useFallbackText: Boolean,
modifier: Modifier = Modifier,
) {
// TODO options for overriding fallback
Box(
modifier =
modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.fillMaxSize()
.align(Alignment.TopCenter),
) {
if (useFallbackText && name.isNotNullOrBlank()) {
Text(
text = name,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
modifier =
Modifier
.padding(8.dp)
.align(Alignment.Center),
)
} else {
Image(
painter = painterResource(id = R.drawable.video_solid),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter =
ColorFilter.tint(
MaterialTheme.colorScheme.onSurfaceVariant,
BlendMode.SrcIn,
),
modifier =
Modifier
.fillMaxSize(.4f)
.align(Alignment.Center),
)
}
}
}
@Composable
fun ItemCardImageOverlay(
favorite: Boolean,
watched: Boolean,
unwatchedCount: Int,
watchedPercent: Double?,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
if (favorite) {
Text(
modifier =
Modifier
.align(Alignment.TopStart)
.padding(8.dp),
color = colorResource(android.R.color.holo_red_light),
text = stringResource(R.string.fa_heart),
fontSize = 20.sp,
fontFamily = FontAwesome,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(4.dp)
.align(Alignment.TopEnd),
) {
if (watched && (watchedPercent == null || watchedPercent <= 0.0 || watchedPercent >= 100.0)) {
WatchedIcon(Modifier.size(24.dp))
}
if (unwatchedCount > 0) {
Box(
modifier =
Modifier
.background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(25),
),
) {
Text(
text = unwatchedCount.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
// fontSize = 16.sp,
modifier = Modifier.padding(4.dp),
)
}
}
}
watchedPercent?.let { percent ->
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.background(
MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape)
.height(Cards.playedPercentHeight)
.fillMaxWidth((percent / 100.0).toFloat()),
)
}
}
}

View file

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

View file

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

View file

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

View file

@ -2,7 +2,13 @@ package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -10,9 +16,35 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.ImageType
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun BoxScope.DetailsBackdropImage(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
val imageUrlService = LocalImageUrlService.current
val backdropImageUrl =
remember(item) {
if (item != null) {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
} else {
null
}
}
DetailsBackdropImage(backdropImageUrl, modifier)
}
@Composable
fun BoxScope.DetailsBackdropImage(
@ -50,3 +82,73 @@ fun BoxScope.DetailsBackdropImage(
)
}
}
@Composable
fun BoxScope.DelayedDetailsBackdropImage(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
val imageUrlService = LocalImageUrlService.current
val backdropImageUrl =
remember(item) {
if (item != null) {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
} else {
null
}
}
DelayedDetailsBackdropImage(backdropImageUrl, modifier)
}
/**
* Shows a backdrop image, but with a crossfade & delay
*
* Used for change backdrops when change items frequently
*/
@Composable
fun BoxScope.DelayedDetailsBackdropImage(
focusedBackdropImageUrl: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var backdropImageUrl by remember { mutableStateOf<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,
)
}
@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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
@ -48,7 +48,7 @@ fun CollectionFolderBoxSet(
positionCallback = { columns, position ->
showHeader = position < columns
},
params = CollectionFolderGridParameters.POSTER,
defaultViewOptions = ViewOptionsPoster,
playEnabled = playEnabled,
)
}

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

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

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

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

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

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

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

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

View file

@ -32,16 +32,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -53,12 +48,12 @@ import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
@ -69,7 +64,6 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -224,35 +218,7 @@ fun PlaylistDetailsContent(
Box(
modifier = modifier,
) {
if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = focusedItem.backdropImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopEnd,
modifier =
Modifier
.fillMaxHeight(.85f)
.alpha(.4f)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = 500f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(gradientColor, Color.Transparent),
endX = 400f,
startX = 100f,
),
)
},
)
}
DelayedDetailsBackdropImage(focusedItem)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
@ -438,7 +404,7 @@ fun PlaylistItem(
style = MaterialTheme.typography.labelLarge,
)
ItemCardImage(
imageUrl = item?.imageUrl,
item = item,
name = item?.name,
showOverlay = true,
favorite = item?.data?.userData?.isFavorite ?: false,

View file

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

View file

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

View file

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

View file

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

View file

@ -28,15 +28,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -45,24 +41,22 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.MovieQuickDetails
import com.github.damontecres.wholphin.ui.components.SeriesQuickDetails
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.RowColumnSaver
@ -79,7 +73,6 @@ import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun HomePage(
@ -242,46 +235,10 @@ fun HomePageContent(
LaunchedEffect(position) {
listState.animateScrollToItem(position.row)
}
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(focusedItem) {
backdropImageUrl = null
delay(150)
backdropImageUrl = focusedItem?.backdropImageUrl
}
Box(modifier = modifier) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model =
ImageRequest
.Builder(context)
.data(backdropImageUrl)
.transitionFactory(CrossFadeFactory(250.milliseconds))
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
modifier =
Modifier
.fillMaxHeight(.7f)
.fillMaxWidth(.7f)
.alpha(.75f)
.align(Alignment.TopEnd)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = size.height * .33f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(gradientColor, Color.Transparent),
startX = 0f,
endX = size.width * .5f,
),
)
},
DelayedDetailsBackdropImage(
item = focusedItem,
modifier = Modifier,
)
Column(modifier = Modifier.fillMaxSize()) {
@ -382,7 +339,7 @@ fun HomePageContent(
cardContent = { index, item, cardModifier, onClick, onLongClick ->
BannerCard(
name = item?.data?.seriesName ?: item?.name,
imageUrl = item?.imageUrl,
item = item,
aspectRatio = AspectRatios.TALL,
cornerText =
item?.data?.indexNumber?.let { "E$it" }
@ -486,10 +443,10 @@ fun HomePageHeader(
overflow = TextOverflow.Ellipsis,
)
}
if (isEpisode) {
EpisodeQuickDetails(dto, Modifier)
} else {
MovieQuickDetails(dto, Modifier)
when (item.type) {
BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier)
BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier)
else -> MovieQuickDetails(dto, Modifier)
}
val overviewModifier =
Modifier

View file

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

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

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

View file

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

View file

@ -10,13 +10,13 @@ import kotlinx.serialization.Serializable
*/
data class PreferenceGroup(
@param:StringRes val title: Int,
val preferences: List<AppPreference<out Any?>>,
val preferences: List<AppPreference<AppPreferences, out Any?>>,
val conditionalPreferences: List<ConditionalPreferences> = listOf(),
)
data class ConditionalPreferences(
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 }
.flatten()
groupPreferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<Any>
pref as AppPreference<AppPreferences, Any>
item {
val interactionSource = remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value

View file

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

View file

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

View file

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

View file

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

View file

@ -329,6 +329,12 @@
<string name="subtitle_margin">Margin</string>
<string name="verbose_logging">Verbose logging</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">
<item>Disabled</item>
@ -404,4 +410,17 @@
<item>MPV (Experimental)</item>
</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>