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