Merge branch 'main' into fea/music

This commit is contained in:
Damontecres 2026-02-24 12:40:57 -05:00
commit cd06c0380e
No known key found for this signature in database
122 changed files with 8844 additions and 1446 deletions

View file

@ -44,19 +44,18 @@ jobs:
id: buildapp
run: |
./gradlew clean assembleDebug testDebugUnitTest --no-daemon
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT"
- name: Tar build dirs
test-patch:
runs-on: ubuntu-latest
needs: pre-commit
steps:
- name: Checkout the code
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Test applying patch
run: |
tar -czf build.tgz ./app/.
- uses: actions/upload-artifact@v6
id: upload-build-dirs
with:
name: "${{ env.BUILD_DIRS_ARTIFACT }}"
path: build.tgz
if-no-files-found: error
- uses: actions/upload-artifact@v6
with:
name: APKs
path: "${{ steps.buildapp.outputs.apks }}"
compression-level: 0
git apply app/src/patches/play_store.patch
git diff

View file

@ -39,11 +39,24 @@ jobs:
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
run: |
./gradlew clean assembleRelease --no-daemon
- name: Build app
id: buildaab
env:
KEY_ALIAS: "${{ secrets.KEY_ALIAS }}"
KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}"
KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}"
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
run: |
git apply app/src/patches/play_store.patch
./gradlew bundleRelease --no-daemon
aab=$(find app/build/outputs -name '*.aab')
echo "aab=$aab" >> "$GITHUB_OUTPUT"
- name: Verify signatures
run: |
echo "Verify APK signatures"
find app/build/outputs/apk -name '*.apk'
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs
echo "Verify APK/AAB signatures"
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \)
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs
- name: Copy APK to shorter names
id: apks
run: |
@ -59,11 +72,18 @@ jobs:
- name: Checksums
run: |
echo "SHA256 checksums:"
find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
- name: Upload AAB
uses: actions/upload-artifact@v6
with:
name: AAB
path: |
app/build/outputs/bundle/**/*.aab
compression-level: 0
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ env.TAG_NAME }}" \
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "Placeholder" --draft \
--latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \
"app/build/outputs/apk/**/*.apk"

View file

@ -30,6 +30,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
### User interface
- Customize the home page to see the content you are interested in
- Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows!
- A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app
- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows
- Option to combine Continue Watching & Next Up rows
@ -112,6 +114,10 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin
## Additional screenshots
### Customized home page
![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1)
### Movie library browsing
<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" />

View file

@ -7,7 +7,6 @@ import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt)
@ -48,39 +47,6 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
isDebuggable = false
}
debug {
isMinifyEnabled = false
isDebuggable = true
applicationIdSuffix = ".debug"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
javaParameters = true
}
}
buildFeatures {
buildConfig = true
compose = true
}
room {
schemaDirectory("$projectDir/schemas")
}
signingConfigs {
if (shouldSign) {
create("ci") {
@ -98,14 +64,15 @@ android {
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
isDebuggable = false
if (shouldSign) {
signingConfig = signingConfigs.getByName("ci")
} else {
@ -120,10 +87,11 @@ android {
}
}
}
debug {
if (shouldSign) {
signingConfig = signingConfigs.getByName("ci")
}
isMinifyEnabled = false
isDebuggable = true
applicationIdSuffix = ".debug"
}
applicationVariants.all {
@ -138,6 +106,25 @@ android {
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlin {
compilerOptions {
languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3
jvmTarget = JvmTarget.JVM_11
javaParameters = true
}
}
buildFeatures {
buildConfig = true
compose = true
}
room {
schemaDirectory("$projectDir/schemas")
}
splits {
abi {
@ -150,7 +137,7 @@ android {
sourceSets {
getByName("main") {
kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin")
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
}
}
}

View file

@ -0,0 +1,642 @@
{
"formatVersion": 1,
"database": {
"version": 31,
"identityHash": "c6829d764ec85321ab3be9905d6c0e3a",
"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, `pin` 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"
},
{
"fieldPath": "pin",
"columnName": "pin",
"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, `order` INTEGER NOT NULL DEFAULT -1, 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
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
}
],
"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"
]
}
]
},
{
"tableName": "playback_effects",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "videoFilter.rotation",
"columnName": "rotation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.brightness",
"columnName": "brightness",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.contrast",
"columnName": "contrast",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.saturation",
"columnName": "saturation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.hue",
"columnName": "hue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.red",
"columnName": "red",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.green",
"columnName": "green",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blue",
"columnName": "blue",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "videoFilter.blur",
"columnName": "blur",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"itemId",
"type"
]
}
},
{
"tableName": "PlaybackLanguageChoice",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "seriesId",
"columnName": "seriesId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "itemId",
"affinity": "TEXT"
},
{
"fieldPath": "audioLanguage",
"columnName": "audioLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitleLanguage",
"columnName": "subtitleLanguage",
"affinity": "TEXT"
},
{
"fieldPath": "subtitlesDisabled",
"columnName": "subtitlesDisabled",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"seriesId"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "ItemTrackModification",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), 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": "trackIndex",
"columnName": "trackIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "delayMs",
"columnName": "delayMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId",
"itemId",
"trackIndex"
]
},
"foreignKeys": [
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"userId"
],
"referencedColumns": [
"rowId"
]
}
]
},
{
"tableName": "seerr_servers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_seerr_servers_url",
"unique": true,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)"
}
]
},
{
"tableName": "seerr_users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinUserRowId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authMethod",
"columnName": "authMethod",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT"
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT"
},
{
"fieldPath": "credential",
"columnName": "credential",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"jellyfinUserRowId",
"serverId"
]
},
"foreignKeys": [
{
"table": "seerr_servers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"serverId"
],
"referencedColumns": [
"id"
]
},
{
"table": "users",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"jellyfinUserRowId"
],
"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, 'c6829d764ec85321ab3be9905d6c0e3a')"
]
}
}

View file

@ -123,6 +123,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
@Inject
lateinit var backdropService: BackdropService
// Note: unused but injected to ensure it is created
@Inject
lateinit var serverEventListener: ServerEventListener
@ -247,6 +250,9 @@ class MainActivity : AppCompatActivity() {
}
is SetupDestination.AppContent -> {
LaunchedEffect(Unit) {
backdropService.clearBackdrop()
}
val current = key.current
ProvideLocalClock {
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
@ -409,7 +415,7 @@ class MainActivity : AppCompatActivity() {
}
fun changeDisplayMode(modeId: Int) {
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler()) {
lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) {
val attrs = window.attributes
if (attrs.preferredDisplayModeId != modeId) {
Timber.d("Switch preferredDisplayModeId to %s", modeId)

View file

@ -40,7 +40,7 @@ import java.util.UUID
SeerrUser::class,
],
version = 30,
version = 31,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@ -54,6 +54,7 @@ import java.util.UUID
AutoMigration(11, 12),
AutoMigration(12, 20),
AutoMigration(20, 30),
AutoMigration(30, 31),
],
)
@TypeConverters(Converters::class)

View file

@ -1,101 +0,0 @@
package com.github.damontecres.wholphin.data
import android.content.Context
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NavDrawerItemRepository
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
) {
suspend fun getNavDrawerItems(): List<NavDrawerItem> {
val user = serverRepository.currentUser.value
val tvAccess =
serverRepository.currentUserDto.value
?.policy
?.enableLiveTvAccess ?: false
val userViews =
api.userViewsApi
.getUserViews(userId = user?.id)
.content.items
val recordingFolders =
if (tvAccess) {
api.liveTvApi
.getRecordingFolders(userId = user?.id)
.content.items
.map { it.id }
.toSet()
} else {
setOf()
}
val builtins =
if (seerrServerRepository.active.first()) {
listOf(
NavDrawerItem.Favorites,
NavDrawerItem.Discover,
NavDrawerItem.NowPlaying,
)
} else {
listOf(NavDrawerItem.Favorites, NavDrawerItem.NowPlaying)
}
val libraries =
userViews
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
.map {
val destination =
if (it.id in recordingFolders) {
Destination.Recordings(it.id)
} else {
BaseItem.from(it, api).destination()
}
ServerNavDrawerItem(
itemId = it.id,
name = it.name ?: it.id.toString(),
destination = destination,
type = it.collectionType ?: CollectionType.UNKNOWN,
)
}
return builtins + libraries
}
suspend fun getFilteredNavDrawerItems(items: List<NavDrawerItem>): List<NavDrawerItem> {
val user = serverRepository.currentUser.value
val navDrawerPins =
user
?.let {
serverPreferencesDao.getNavDrawerPinnedItems(it)
}.orEmpty()
val filtered = items.filter { navDrawerPins.isPinned(it.id) }
if (items.size != filtered.size) {
// Some were filtered out, check if should include More
if (navDrawerPins.isPinned(NavDrawerItem.More.id)) {
return filtered + listOf(NavDrawerItem.More)
}
}
return filtered
}
}
fun List<NavDrawerPinnedItem>.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult
@ -265,6 +266,17 @@ class ServerRepository
}
}
suspend fun authorizeQuickConnect(code: String): Boolean =
withContext(Dispatchers.IO) {
val userId = currentUser.value?.id
if (userId == null) {
Timber.e("No user logged in for Quick Connect authorization")
throw IllegalStateException("Must be logged in to authorize Quick Connect")
}
val response = apiClient.quickConnectApi.authorizeQuickConnect(code, userId)
response.content
}
companion object {
fun getServerSharedPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(

View file

@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale
import java.util.UUID
import kotlin.time.Duration
@Serializable
@ -32,6 +33,8 @@ import kotlin.time.Duration
data class BaseItem(
val data: BaseItemDto,
val useSeriesForPrimary: Boolean,
val imageUrlOverride: String? = null,
val destinationOverride: Destination? = null,
) : CardGridItem {
val id get() = data.id
@ -165,6 +168,7 @@ data class BaseItem(
}?.toIntOrNull()
fun destination(index: Int? = null): Destination {
if (destinationOverride != null) return destinationOverride
val result =
// Redirect episodes & seasons to their series if possible
when (type) {
@ -186,6 +190,25 @@ data class BaseItem(
)
}
BaseItemKind.TV_CHANNEL -> {
Destination.Playback(
itemId = id,
positionMs = 0L,
)
}
BaseItemKind.PROGRAM -> {
val channelId = data.channelId
if (channelId != null) {
Destination.Playback(
itemId = channelId,
positionMs = 0L,
)
} else {
Destination.MediaItem(this)
}
}
else -> {
Destination.MediaItem(this)
}
@ -214,3 +237,28 @@ data class BaseItemUi(
val episodeUnplayedCornerText: String?,
val quickDetails: AnnotatedString,
)
fun createGenreDestination(
genreId: UUID,
genreName: String,
parentId: UUID,
parentName: String?,
includeItemTypes: List<BaseItemKind>?,
) = Destination.FilteredCollection(
itemId = parentId,
filter =
CollectionFolderFilter(
nameOverride =
listOfNotNull(
genreName,
parentName,
).joinToString(" "),
filter =
GetItemsFilter(
genres = listOf(genreId),
includeItemTypes = includeItemTypes,
),
useSavedLibraryDisplayInfo = false,
),
recursive = true,
)

View file

@ -31,6 +31,7 @@ data class Chapter(
)
},
)
}.orEmpty()
}?.sortedBy { it.position }
.orEmpty()
}
}

View file

@ -0,0 +1,239 @@
@file:UseSerializers(UUIDSerializer::class)
package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Serializable
sealed interface HomeRowConfig {
val viewOptions: HomeRowViewOptions
fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig
/**
* Continue watching media that the user has started but not finished
*/
@Serializable
@SerialName("ContinueWatching")
data class ContinueWatching(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatching = this.copy(viewOptions = viewOptions)
}
/**
* Next up row for next episodes in a series the user has started
*/
@Serializable
@SerialName("NextUp")
data class NextUp(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): NextUp = this.copy(viewOptions = viewOptions)
}
/**
* Combined [ContinueWatching] and [NextUp]
*/
@Serializable
@SerialName("ContinueWatchingCombined")
data class ContinueWatchingCombined(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatchingCombined = this.copy(viewOptions = viewOptions)
}
/**
* Media recently added to a library
*/
@Serializable
@SerialName("RecentlyAdded")
data class RecentlyAdded(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyAdded = this.copy(viewOptions = viewOptions)
}
/**
* Media recently released (premiere date) in a library
*/
@Serializable
@SerialName("RecentlyReleased")
data class RecentlyReleased(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyReleased = this.copy(viewOptions = viewOptions)
}
/**
* Row of a genres in a library
*/
@Serializable
@SerialName("Genres")
data class Genres(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault,
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions)
}
/**
* Favorites for a specific type
*/
@Serializable
@SerialName("Favorite")
data class Favorite(
val kind: BaseItemKind,
override val viewOptions: HomeRowViewOptions =
if (kind == BaseItemKind.EPISODE) {
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.WIDE,
)
} else {
HomeRowViewOptions()
},
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
}
/**
* Currently recording
*/
@Serializable
@SerialName("Recordings")
data class Recordings(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Recordings = this.copy(viewOptions = viewOptions)
}
/**
* Programs on now/recommended
*/
@Serializable
@SerialName("TvPrograms")
data class TvPrograms(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault,
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions)
}
/**
* Live TV channels
*/
@Serializable
@SerialName("TvChannels")
data class TvChannels(
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault,
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvChannels = this.copy(viewOptions = viewOptions)
}
/**
* Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID
*/
@Serializable
@SerialName("Suggestions")
data class Suggestions(
val parentId: UUID,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Suggestions = this.copy(viewOptions = viewOptions)
}
/**
* Fetch by parent ID such as a library, collection, or playlist with optional simple sorting
*/
@Serializable
@SerialName("ByParent")
data class ByParent(
val parentId: UUID,
val recursive: Boolean,
val sort: SortAndDirection? = null,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): ByParent = this.copy(viewOptions = viewOptions)
}
/**
* An arbitrary [GetItemsRequest] allowing to query for anything
*/
@Serializable
@SerialName("GetItems")
data class GetItems(
val name: String,
val getItems: GetItemsRequest,
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions)
}
}
/**
* Root class for home page settings
*
* Contains the list of rows and a version
*/
@Serializable
@SerialName("HomePageSettings")
data class HomePageSettings(
val rows: List<HomeRowConfig>,
val version: Int,
) {
companion object {
val EMPTY = HomePageSettings(listOf(), SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
}
}
/**
* This is the max version supported by this version of the app
*/
const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1
/**
* View options for displaying a row
*
* Allows for changing things like height or aspect ratio
*/
@Serializable
data class HomeRowViewOptions(
val heightDp: Int = Cards.HEIGHT_2X3_DP,
val spacing: Int = 16,
val contentScale: PrefContentScale = PrefContentScale.FILL,
val aspectRatio: AspectRatio = AspectRatio.TALL,
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
val showTitles: Boolean = false,
val useSeries: Boolean = true,
val episodeContentScale: PrefContentScale = PrefContentScale.FILL,
val episodeAspectRatio: AspectRatio = AspectRatio.TALL,
val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
) {
companion object {
val genreDefault =
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.WIDE,
)
val liveTvDefault =
HomeRowViewOptions(
heightDp = 96,
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.FIT,
)
}
}

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@ -24,4 +25,5 @@ data class NavDrawerPinnedItem(
val userId: Int,
val itemId: String,
val type: NavPinType,
@ColumnInfo(defaultValue = "-1") val order: Int,
)

View file

@ -656,6 +656,13 @@ sealed interface AppPreference<Pref, T> {
setter = { prefs, _ -> prefs },
)
val CustomizeHome =
AppDestinationPreference<AppPreferences>(
title = R.string.customize_home,
destination = Destination.HomeSettings,
summary = R.string.customize_home_summary,
)
val SendCrashReports =
AppSwitchPreference<AppPreferences>(
title = R.string.send_crash_reports,
@ -943,6 +950,14 @@ sealed interface AppPreference<Pref, T> {
setter = { prefs, _ -> prefs },
)
val QuickConnect =
AppClickablePreference<AppPreferences>(
title = R.string.quick_connect,
summary = R.string.quick_connect_summary,
getter = { },
setter = { prefs, _ -> prefs },
)
val SlideshowDuration =
AppSliderPreference<AppPreferences>(
title = R.string.slideshow_duration,
@ -992,10 +1007,6 @@ val basicPreferences =
preferences =
listOf(
AppPreference.SignInAuto,
AppPreference.HomePageItems,
AppPreference.CombineContinueNext,
AppPreference.RewatchNextUp,
AppPreference.MaxDaysNextUp,
AppPreference.PlayThemeMusic,
AppPreference.RememberSelectedTab,
AppPreference.SubtitleStyle,
@ -1026,6 +1037,7 @@ val basicPreferences =
preferences =
listOf(
AppPreference.RequireProfilePin,
AppPreference.CustomizeHome,
AppPreference.UserPinnedNavDrawerItems,
),
),
@ -1091,6 +1103,7 @@ val advancedPreferences =
preferences =
listOf(
AppPreference.ShowClock,
AppPreference.CombineContinueNext,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout,
@ -1168,6 +1181,7 @@ val advancedPreferences =
title = R.string.more,
preferences =
listOf(
AppPreference.QuickConnect,
AppPreference.SendAppLogs,
AppPreference.SendCrashReports,
AppPreference.DebugLogging,

View file

@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber
import javax.inject.Inject
@ -47,7 +48,12 @@ class BackdropService
suspend fun submit(item: BaseItem) =
withContext(Dispatchers.IO) {
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
val imageUrl =
if (item.type == BaseItemKind.GENRE) {
item.imageUrlOverride
} else {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
}
submit(item.id.toString(), imageUrl)
}

File diff suppressed because it is too large Load diff

View file

@ -30,6 +30,9 @@ class ImageUrlService
useSeriesForPrimary: Boolean,
imageTags: Map<ImageType, String?>,
imageType: ImageType,
parentThumbId: UUID? = null,
parentBackdropId: UUID? = null,
backdropTags: List<String> = emptyList(),
fillWidth: Int? = null,
fillHeight: Int? = null,
): String? =
@ -54,8 +57,65 @@ class ImageUrlService
}
}
ImageType.THUMB -> {
if (useSeriesForPrimary && parentThumbId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
) {
// Use parent's thumb
getItemImageUrl(
itemId = parentThumbId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (useSeriesForPrimary && parentBackdropId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
) {
// No parent thumb, so use backdrop instead
getItemImageUrl(
itemId = parentBackdropId,
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl(
itemId = parentThumbId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (useSeriesForPrimary &&
parentThumbId == null &&
itemType == BaseItemKind.EPISODE &&
imageType !in imageTags
) {
// Workaround to fall back to episode image if no parent thumb
getItemImageUrl(
itemId = itemId,
imageType = ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
// If no thumb, use backdrop if available
getItemImageUrl(
itemId = itemId,
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
getItemImageUrl(
itemId = itemId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
}
}
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.BANNER,
-> {
if (useSeriesForPrimary && seriesId != null &&
@ -99,15 +159,19 @@ class ImageUrlService
imageType: ImageType,
fillWidth: Int? = null,
fillHeight: Int? = null,
useSeriesForPrimary: Boolean? = null,
): String? =
if (item != null) {
getItemImageUrl(
itemId = item.id,
itemType = item.type,
seriesId = item.data.seriesId,
useSeriesForPrimary = item.useSeriesForPrimary,
useSeriesForPrimary = useSeriesForPrimary ?: item.useSeriesForPrimary,
imageTags = item.data.imageTags.orEmpty(),
imageType = imageType,
parentThumbId = item.data.parentThumbItemId,
parentBackdropId = item.data.parentBackdropItemId,
backdropTags = item.data.backdropImageTags.orEmpty(),
fillWidth = fillWidth,
fillHeight = fillHeight,
)

View file

@ -4,8 +4,6 @@ import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.main.LatestData
import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.qualifiers.ApplicationContext
@ -21,6 +19,8 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
@ -44,6 +44,7 @@ class LatestNextUpService
userId: UUID,
limit: Int,
includeEpisodes: Boolean,
useSeriesForPrimary: Boolean = true,
): List<BaseItem> {
val request =
GetResumeItemsRequest(
@ -60,13 +61,19 @@ class LatestNextUpService
remove(BaseItemKind.EPISODE)
}
},
enableImageTypes =
listOf(
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.BACKDROP,
),
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { BaseItem.from(it, api, true) }
.map { BaseItem.from(it, api, useSeriesForPrimary) }
return items
}
@ -76,6 +83,7 @@ class LatestNextUpService
enableRewatching: Boolean,
enableResumable: Boolean,
maxDays: Int,
useSeriesForPrimary: Boolean = true,
): List<BaseItem> {
val nextUpDateCutoff =
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
@ -96,7 +104,7 @@ class LatestNextUpService
.getNextUp(request)
.content
.items
.map { BaseItem.from(it, api, true) }
.map { BaseItem.from(it, api, useSeriesForPrimary) }
return nextUp
}
@ -192,3 +200,17 @@ class LatestNextUpService
return@withContext result
}
}
val supportedLatestCollectionTypes =
setOf(
CollectionType.MOVIES,
CollectionType.TVSHOWS,
CollectionType.HOMEVIDEOS,
// Exclude Live TV because a recording folder view will be used instead
null, // Recordings & mixed collection types
)
data class LatestData(
val title: String,
val request: GetLatestMediaRequest,
)

View file

@ -0,0 +1,184 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.lifecycle.asFlow
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.UserDto
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NavDrawerService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:DefaultCoroutineScope private val coroutineScope: CoroutineScope,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
) {
private val _state = MutableStateFlow(NavDrawerItemState.EMPTY)
val state: StateFlow<NavDrawerItemState> = _state
init {
serverRepository.currentUser
.asFlow()
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
Pair(user, userDto)
}.onEach { (user, userDto) ->
Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id)
_state.update {
it.copy(
items = emptyList(),
moreItems = emptyList(),
)
}
if (user != null && userDto != null && user.id == userDto.id) {
updateNavDrawer(user, userDto)
}
}.launchIn(coroutineScope)
seerrServerRepository.active
.onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) }
}.launchIn(coroutineScope)
}
suspend fun getAllUserLibraries(
userId: UUID,
tvAccess: Boolean,
): List<Library> {
val userViews =
api.userViewsApi
.getUserViews(userId = userId)
.content.items
val recordingFolders =
if (tvAccess) {
api.liveTvApi
.getRecordingFolders(userId = userId)
.content.items
.map { it.id }
.toSet()
} else {
setOf()
}
val libraries =
userViews
.filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders }
.map {
Library(
itemId = it.id,
name = it.name ?: "",
type = it.type,
collectionType = it.collectionType ?: CollectionType.UNKNOWN,
isRecordingFolder = it.id in recordingFolders,
)
}
return libraries
}
suspend fun getFilteredUserLibraries(
user: JellyfinUser,
tvAccess: Boolean,
): List<Library> {
val pins =
serverPreferencesDao
.getNavDrawerPinnedItems(user)
.associateBy { it.itemId }
val libraries =
getAllUserLibraries(user.id, tvAccess)
.filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED }
return libraries
}
suspend fun updateNavDrawer(
user: JellyfinUser,
userDto: UserDto,
) {
val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover)
val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess)
val libraries =
allLibraries
.map {
val destination =
if (it.isRecordingFolder) {
Destination.Recordings(it.itemId)
} else {
Destination.MediaItem(
it.itemId,
it.type,
it.collectionType,
)
}
ServerNavDrawerItem(
itemId = it.itemId,
name = it.name,
destination = destination,
type = it.collectionType,
)
}
val allItems = builtins + libraries
val navDrawerPins =
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
val items = mutableListOf<NavDrawerItem>()
val moreItems = mutableListOf<NavDrawerItem>()
allItems
// Sort by order if non-default, existing items before customize will have -1 value
// New items from the server will get Int.MAX_VALUE
// Items the user doesn't have access to anymore will be skipped
.sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE }
.forEach {
// Assume pinned if unknown
val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
if (pinned == NavPinType.PINNED) {
items.add(it)
} else {
moreItems.add(it)
}
}
_state.update {
it.copy(
items = items,
moreItems = moreItems,
)
}
}
}
data class NavDrawerItemState(
val items: List<NavDrawerItem>,
val moreItems: List<NavDrawerItem>,
val discoverEnabled: Boolean,
) {
companion object {
val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false)
}
}
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true

View file

@ -28,21 +28,6 @@ class RefreshRateService
constructor(
@param:ApplicationContext private val context: Context,
) {
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
private val display get() = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
val supportedDisplayModes get() = display.supportedModes.orEmpty()
private val displayModes: List<DisplayMode> by lazy {
display.supportedModes
.orEmpty()
.map { DisplayMode(it) }
.sortedWith(
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
.thenBy { it.refreshRateRounded },
)
}
/**
* Find the best display mode for the given stream and signal to change to it
*/
@ -55,6 +40,18 @@ class RefreshRateService
Timber.v("Not switching either refresh rate nor resolution")
return@withContext
}
val displayManager =
MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
val displayModes =
display.supportedModes
.orEmpty()
.map { DisplayMode(it) }
.sortedWith(
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
.thenBy { it.refreshRateRounded },
)
val currentDisplayMode = display.mode
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
val width = stream.width

View file

@ -44,18 +44,33 @@ class SeerrServerRepository
private val serverRepository: ServerRepository,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
private val _current = MutableStateFlow<CurrentSeerr?>(null)
val current: StateFlow<CurrentSeerr?> = _current
val currentServer: Flow<SeerrServer?> = current.map { it?.server }
val currentUser: Flow<SeerrUser?> = current.map { it?.user }
private val _connection =
MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured)
val connection: StateFlow<SeerrConnectionStatus> = _connection
val current: Flow<CurrentSeerr?> =
_connection.map { (it as? SeerrConnectionStatus.Success)?.current }
val currentServer: Flow<SeerrServer?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow<SeerrUser?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
/**
* Whether Seerr integration is currently active of not
*/
val active: Flow<Boolean> = current.map { it != null && seerrApi.active }
val active: Flow<Boolean> =
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
fun clear() {
_current.update { null }
_connection.update { SeerrConnectionStatus.NotConfigured }
seerrApi.update("", null)
}
fun error(
serverUrl: String,
exception: Exception,
) {
_connection.update { SeerrConnectionStatus.Error(serverUrl, exception) }
seerrApi.update("", null)
}
@ -65,8 +80,10 @@ class SeerrServerRepository
userConfig: SeerrUserConfig,
) {
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
_current.update {
CurrentSeerr(server, user, userConfig, publicSettings)
_connection.update {
SeerrConnectionStatus.Success(
CurrentSeerr(server, user, userConfig, publicSettings),
)
}
}
@ -154,7 +171,7 @@ class SeerrServerRepository
}
suspend fun removeServer() {
val current = _current.value ?: return
val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
clear()
}
@ -165,6 +182,19 @@ class SeerrServerRepository
*/
typealias SeerrUserConfig = User
sealed interface SeerrConnectionStatus {
data object NotConfigured : SeerrConnectionStatus
data class Error(
val serverUrl: String,
val ex: Exception,
) : SeerrConnectionStatus
data class Success(
val current: CurrentSeerr,
) : SeerrConnectionStatus
}
data class CurrentSeerr(
val server: SeerrServer,
val user: SeerrUser,
@ -226,6 +256,7 @@ class UserSwitchListener
private val seerrServerRepository: SeerrServerRepository,
private val seerrServerDao: SeerrServerDao,
private val seerrApi: SeerrApi,
private val homeSettingsService: HomeSettingsService,
) {
init {
context as AppCompatActivity
@ -233,39 +264,45 @@ class UserSwitchListener
serverRepository.currentUser.asFlow().collect { user ->
Timber.d("New user")
seerrServerRepository.clear()
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) {
// Check for home settings
launchIO {
homeSettingsService.loadCurrentSettings(user.id)
}
// Check for seerr server
launchIO {
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.firstOrNull()
.lastOrNull()
?.let { seerrUser ->
val server = seerrServerDao.getServer(seerrUser.serverId)?.server
val server =
seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
try {
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
try {
login(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} catch (ex: Exception) {
Timber.w(ex, "Error logging into %s", server.url)
seerrServerRepository.clear()
return@let
}
} else {
try {
seerrApi.api.usersApi.authMeGet()
} catch (ex: Exception) {
Timber.w(ex, "Error logging into %s", server.url)
seerrServerRepository.clear()
return@let
}
}
seerrServerRepository.set(server, seerrUser, userConfig)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server.url, ex)
}
}
}
}
}

View file

@ -6,6 +6,7 @@ import androidx.work.WorkManager
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
@ -49,21 +50,8 @@ class SuggestionService
.asFlow()
.flatMapLatest { user ->
val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty)
val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty()
if (cachedIds.isNotEmpty()) {
flow {
try {
emit(
SuggestionsResource.Success(
fetchItemsByIds(cachedIds, itemKind),
),
)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
} else {
val cachedSuggestions = cache.get(userId, parentId, itemKind)
if (cachedSuggestions == null) {
workManager
.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME)
.map { workInfos ->
@ -73,6 +61,23 @@ class SuggestionService
}
if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty
}
} else if (cachedSuggestions.ids.isEmpty()) {
flowOf(SuggestionsResource.Empty)
} else {
flow {
try {
emit(
SuggestionsResource.Success(
fetchItemsByIds(cachedSuggestions.ids, itemKind),
),
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Failed to fetch items")
emit(SuggestionsResource.Empty)
}
}
}
}
}

View file

@ -60,11 +60,15 @@ class SuggestionsCache
): CachedSuggestions? {
val key = cacheKey(userId, libraryId, itemKind)
return memoryCache.getOrPut(key) {
try {
mutex.withLock {
File(cacheDir, "$key.json").inputStream().use {
json.decodeFromStream<CachedSuggestions>(it)
try {
val cacheFile = File(cacheDir, "$key.json")
if (!cacheFile.exists()) {
return@withLock null
}
cacheFile.inputStream().use {
json.decodeFromStream<CachedSuggestions>(it)
}
} catch (ex: Exception) {
Timber.e(ex, "Exception reading from disk cache")
@ -72,6 +76,7 @@ class SuggestionsCache
}
}
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun put(

View file

@ -85,11 +85,8 @@ class SuggestionsWorker
views
.mapNotNull { view ->
val itemKind =
when (view.collectionType) {
CollectionType.MOVIES -> BaseItemKind.MOVIE
CollectionType.TVSHOWS -> BaseItemKind.SERIES
else -> return@mapNotNull null
}
getTypeForCollection(view.collectionType)
?: return@mapNotNull null
async(Dispatchers.IO) {
runCatching {
Timber.v("Fetching suggestions for view %s", view.id)
@ -267,5 +264,12 @@ class SuggestionsWorker
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
const val PARAM_USER_ID = "userId"
const val PARAM_SERVER_ID = "serverId"
fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? =
when (collectionType) {
CollectionType.MOVIES -> BaseItemKind.MOVIE
CollectionType.TVSHOWS -> BaseItemKind.SERIES
else -> null
}
}
}

View file

@ -44,6 +44,10 @@ annotation class StandardOkHttpClient
@Retention(AnnotationRetention.BINARY)
annotation class IoCoroutineScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@ -177,6 +181,11 @@ object AppModule {
@IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
@DefaultCoroutineScope
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun workManager(

View file

@ -80,7 +80,6 @@ class TvProviderWorker
getPotentialItems(
userId,
prefs.homePagePreferences.enableRewatchingNextUp,
prefs.homePagePreferences.combineContinueNext,
prefs.homePagePreferences.maxDaysNextUp,
)
val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() }
@ -145,7 +144,6 @@ class TvProviderWorker
private suspend fun getPotentialItems(
userId: UUID,
enableRewatching: Boolean,
combineContinueNext: Boolean,
maxDaysNextUp: Int,
): List<BaseItem> {
val resumeItems = latestNextUpService.getResume(userId, 10, true)
@ -154,11 +152,7 @@ class TvProviderWorker
latestNextUpService
.getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp)
.filter { it.data.seriesId != null && it.data.seriesId !in seriesIds }
return if (combineContinueNext) {
latestNextUpService.buildCombined(resumeItems, nextUpItems)
} else {
resumeItems + nextUpItems
}
return latestNextUpService.buildCombined(resumeItems, nextUpItems)
}
private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> =

View file

@ -391,6 +391,15 @@ fun CoroutineScope.launchIO(
block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = Dispatchers.IO + context, start = start, block = block)
/**
* Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler]
*/
fun CoroutineScope.launchDefault(
context: CoroutineContext = ExceptionHandler(),
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
): Job = launch(context = Dispatchers.Default + context, start = start, block = block)
/**
* Converts a UUID to the format used server-side (ie without hyphens).
*

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber
@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String?
append(productionYear.toString())
if (status == "Continuing") {
append(" - ")
append("Present")
append(WholphinApplication.instance.getString(R.string.series_continueing))
} else if (status == "Ended") {
endDate?.let {
if (it.year != productionYear) {

View file

@ -82,8 +82,10 @@ val PhotoItemFields =
)
object Cards {
val height2x3 = 172.dp
val heightEpisode = height2x3 * .75f
const val HEIGHT_2X3_DP = 172
val height2x3 = HEIGHT_2X3_DP.dp
const val HEIGHT_EPISODE = 128
val heightEpisode = HEIGHT_EPISODE.dp
val playedPercentHeight = 6.dp
val serverUserCircle = height2x3 * .75f
}

View file

@ -1,15 +1,19 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -24,6 +28,7 @@ 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -41,6 +46,7 @@ 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.enableMarquee
import org.jellyfin.sdk.model.api.ImageType
/**
@ -60,6 +66,9 @@ fun BannerCard(
cardHeight: Dp = 120.dp,
aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource? = null,
imageType: ImageType = ImageType.PRIMARY,
imageContentScale: ContentScale = ContentScale.FillBounds,
useSeriesForPrimary: Boolean = true,
) {
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
@ -74,19 +83,21 @@ fun BannerCard(
}
}
val imageUrl =
remember(item, fillHeight) {
remember(item, fillHeight, imageType, useSeriesForPrimary) {
if (item != null) {
imageUrlService.getItemImageUrl(
item.imageUrlOverride
?: imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
imageType,
fillWidth = null,
fillHeight = fillHeight,
useSeriesForPrimary = useSeriesForPrimary,
)
} else {
null
}
}
var imageError by remember { mutableStateOf(false) }
var imageError by remember(imageUrl) { mutableStateOf(false) }
Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
onClick = onClick,
@ -107,7 +118,7 @@ fun BannerCard(
AsyncImage(
model = imageUrl,
contentDescription = null,
contentScale = ContentScale.FillBounds,
contentScale = imageContentScale,
onError = { imageError = true },
modifier = Modifier.fillMaxSize(),
)
@ -115,7 +126,7 @@ fun BannerCard(
Text(
text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier =
Modifier
@ -181,3 +192,84 @@ fun BannerCard(
}
}
}
@Composable
fun BannerCardWithTitle(
title: String?,
subtitle: String?,
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
cornerText: String? = null,
played: Boolean = false,
favorite: Boolean = false,
playPercent: Double = 0.0,
cardHeight: Dp = 120.dp,
aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
imageType: ImageType = ImageType.PRIMARY,
imageContentScale: ContentScale = ContentScale.FillBounds,
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = cardHeight * aspectRationToUse
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.width(width),
) {
BannerCard(
name = null,
item = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier,
cornerText = cornerText,
played = played,
favorite = favorite,
playPercent = playPercent,
cardHeight = cardHeight,
aspectRatio = aspectRatio,
interactionSource = interactionSource,
imageType = imageType,
imageContentScale = imageContentScale,
useSeriesForPrimary = useSeriesForPrimary,
)
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = title ?: "",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = subtitle ?: "",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Normal,
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}

View file

@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -183,6 +184,8 @@ fun DiscoverItemCard(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@ -193,6 +196,8 @@ fun DiscoverItemCard(
text = item?.releaseDate?.year?.toString() ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()

View file

@ -22,11 +22,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
@ -130,6 +132,8 @@ fun EpisodeCard(
text = dto?.seriesName ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@ -140,6 +144,8 @@ fun EpisodeCard(
text = item?.name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()

View file

@ -42,11 +42,29 @@ fun GenreCard(
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = GenreCard(
genreId = genre?.id,
name = genre?.name,
imageUrl = genre?.imageUrl,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
interactionSource = interactionSource,
)
@Composable
fun GenreCard(
genreId: UUID?,
name: String?,
imageUrl: String?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val background = rememberIdColor(genre?.id).copy(alpha = .6f)
val background = rememberIdColor(genreId).copy(alpha = .6f)
Card(
modifier =
modifier,
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
@ -63,12 +81,12 @@ fun GenreCard(
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
) {
if (genre?.imageUrl.isNotNullOrBlank()) {
if (imageUrl != null) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(genre.imageUrl)
.data(imageUrl)
.crossfade(true)
.build(),
contentScale = ContentScale.FillBounds,
@ -88,7 +106,7 @@ fun GenreCard(
.background(background),
) {
Text(
text = genre?.name ?: "",
text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
@ -112,7 +130,6 @@ private fun GenreCardPreview() {
UUID.randomUUID(),
"Adventure",
null,
Color.Black,
)
GenreCard(
genre = genre,

View file

@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -113,6 +114,8 @@ fun GridCard(
text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
@ -124,6 +127,8 @@ fun GridCard(
text = item?.subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@ -57,7 +58,7 @@ fun <T> ItemRow(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
modifier = Modifier.padding(start = 8.dp),
)
LazyRow(
state = state,

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -174,6 +175,8 @@ fun PersonCard(
text = name ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@ -185,6 +188,8 @@ fun PersonCard(
text = role,
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()

View file

@ -16,15 +16,16 @@ 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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
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
@ -126,21 +127,7 @@ fun SeasonCard(
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = imageHeight * aspectRationToUse
val height = imageWidth * (1f / aspectRationToUse)
@ -193,6 +180,8 @@ fun SeasonCard(
text = title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
modifier =
Modifier
.fillMaxWidth()
@ -203,6 +192,8 @@ fun SeasonCard(
text = subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Normal,
modifier =
Modifier
.fillMaxWidth()
@ -212,3 +203,22 @@ fun SeasonCard(
}
}
}
/**
* Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while
*/
@Composable
fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State<Boolean> {
val focused by interactionSource.collectIsFocusedAsState()
val state = remember { mutableStateOf(false) }
LaunchedEffect(focused) {
if (!focused) {
state.value = false
return@LaunchedEffect
}
delay(500L)
state.value = true
}
return state
}

View file

@ -6,7 +6,9 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -33,6 +35,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -41,6 +44,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
@ -60,6 +64,8 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.SlimItemFields
@ -81,6 +87,7 @@ 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.ui.util.FilterUtils
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -120,7 +127,9 @@ class CollectionFolderViewModel
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
val navigationManager: NavigationManager,
private val navigationManager: NavigationManager,
private val themeSongPlayer: ThemeSongPlayer,
private val userPreferencesService: UserPreferencesService,
val mediaReportService: MediaReportService,
@Assisted itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?,
@ -157,6 +166,7 @@ class CollectionFolderViewModel
viewModelScope.launchIO {
super.itemId = itemId
try {
val item =
itemId.toUUIDOrNull()?.let {
fetchItem(it)
}
@ -184,6 +194,8 @@ class CollectionFolderViewModel
}
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
.join()
// onResumePage()
} catch (ex: Exception) {
Timber.e(ex, "Error during init")
loading.setValueOnMain(DataLoadingState.Error(ex))
@ -254,8 +266,7 @@ class CollectionFolderViewModel
recursive: Boolean,
filter: GetItemsFilter,
useSeriesForPrimary: Boolean,
) {
viewModelScope.launch(Dispatchers.IO) {
) = viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
if (resetState) {
loading.value = DataLoadingState.Loading
@ -284,7 +295,6 @@ class CollectionFolderViewModel
}
}
}
}
private fun createPager(
sortAndDirection: SortAndDirection,
@ -346,7 +356,12 @@ class CollectionFolderViewModel
filter.applyTo(
GetItemsRequest(
parentId = item?.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
enableImageTypes =
listOf(
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.BACKDROP,
),
includeItemTypes = includeItemTypes,
recursive = recursive,
excludeItemIds = item?.let { listOf(item.id) },
@ -438,6 +453,30 @@ class CollectionFolderViewModel
backdropService.submit(item)
}
}
fun navigateTo(destination: Destination) {
release()
navigationManager.navigateTo(destination)
}
fun release() {
themeSongPlayer.stop()
}
fun onResumePage() {
viewModelScope.launchIO {
item.value?.let {
Timber.v("onResumePage: %s", loading.value!!::class)
if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
val volume =
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.playThemeSongs
themeSongPlayer.playThemeFor(it.id, volume)
}
}
}
}
}
/**
@ -531,7 +570,7 @@ fun CollectionFolderGrid(
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
is DataLoadingState.Error,
@ -543,6 +582,13 @@ fun CollectionFolderGrid(
?: item?.data?.collectionType?.name
?: stringResource(R.string.collection)
Box(modifier = modifier) {
LifecycleResumeEffect(itemId) {
viewModel.onResumePage()
onPauseOrDispose {
viewModel.release()
}
}
CollectionFolderGridContent(
preferences = preferences,
initialPosition = viewModel.position,
@ -593,7 +639,7 @@ fun CollectionFolderGrid(
} else {
Destination.Playback(item)
}
viewModel.navigationManager.navigateTo(destination)
viewModel.navigateTo(destination)
},
onClickPlayAll = { shuffle ->
itemId.toUUIDOrNull()?.let {
@ -617,7 +663,7 @@ fun CollectionFolderGrid(
filter = filter,
)
}
viewModel.navigationManager.navigateTo(destination)
viewModel.navigateTo(destination)
}
},
)
@ -655,7 +701,7 @@ fun CollectionFolderGrid(
favorite = item.favorite,
actions =
MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) },
navigateTo = { viewModel.navigateTo(it) },
onClickWatch = { itemId, watched ->
viewModel.setWatched(position, itemId, watched)
},
@ -693,6 +739,7 @@ fun CollectionFolderGrid(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CollectionFolderGridContent(
preferences: UserPreferences,
@ -837,14 +884,16 @@ fun CollectionFolderGridContent(
}
}
}
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
val density = LocalDensity.current
AnimatedVisibility(viewOptions.showDetails) {
HomePageHeader(
item = focusedItem,
modifier =
Modifier
.fillMaxWidth()
.height(140.dp)
.padding(16.dp),
.height(200.dp)
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
)
}
when (val state = loadingState) {
@ -852,7 +901,7 @@ fun CollectionFolderGridContent(
DataLoadingState.Loading,
-> {
// This shouldn't happen, so just show placeholder
Text("Loading")
Text(stringResource(R.string.loading))
}
is DataLoadingState.Error -> {
@ -890,6 +939,15 @@ fun CollectionFolderGridContent(
},
columns = viewOptions.columns,
spacing = viewOptions.spacing.dp,
bringIntoViewSpec =
remember(viewOptions) {
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
if (viewOptions.showDetails) {
ScrollToTopBringIntoViewSpec(spacingPx)
} else {
defaultBringIntoViewSpec
}
},
)
AnimatedVisibility(showViewOptions) {
ViewOptionsDialog(

View file

@ -408,12 +408,13 @@ fun ConfirmDialog(
onConfirm: () -> Unit,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) = BasicDialog(
onDismissRequest = onCancel,
properties = properties,
elevation = elevation,
content = {
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier)
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
},
)
@ -427,6 +428,7 @@ fun ConfirmDialogContent(
onCancel: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -446,7 +448,7 @@ fun ConfirmDialogContent(
item {
Text(
text = body,
color = MaterialTheme.colorScheme.onSurface,
color = bodyColor,
)
}
}

View file

@ -0,0 +1,78 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
/**
* Placeholder for [com.github.damontecres.wholphin.ui.cards.ItemRow]. It is [focusable] so it can be scrolled.
*/
@Composable
@NonRestartableComposable
fun FocusableItemRow(
title: String,
subtitle: String,
modifier: Modifier = Modifier,
isError: Boolean = false,
) = FocusableItemRow(
titleContent = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
},
subtitleContent = {
Text(
text = subtitle,
style = MaterialTheme.typography.titleMedium,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
)
},
modifier = modifier,
)
@Composable
fun FocusableItemRow(
titleContent: @Composable () -> Unit,
subtitleContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
val background by animateColorAsState(
if (focused) {
MaterialTheme.colorScheme.border.copy(alpha = .25f)
} else {
Color.Unspecified
},
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.padding(start = 8.dp)
.focusable(interactionSource = interactionSource)
.background(background, shape = RoundedCornerShape(8.dp))
.padding(8.dp),
) {
titleContent.invoke()
subtitleContent.invoke()
}
}

View file

@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
@ -21,8 +21,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.createGenreDestination
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -30,17 +29,18 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.mayakapps.kache.InMemoryKache
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -56,8 +56,10 @@ import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.hours
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
class GenreViewModel
@ -102,51 +104,23 @@ class GenreViewModel
.execute(api, request)
.content.items
.map {
Genre(it.id, it.name ?: "", null, Color.Black)
Genre(it.id, it.name ?: "", null)
}
withContext(Dispatchers.Main) {
this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success
}
val genreToUrl = ConcurrentHashMap<UUID, String?>()
val semaphore = Semaphore(4)
genres
.map { genre ->
viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit {
val item =
GetItemsRequestHandler
.execute(
api,
GetItemsRequest(
val genreToUrl =
getGenreImageMap(
api = api,
userId = serverRepository.currentUser.value?.id,
scope = viewModelScope,
imageUrlService = imageUrlService,
genres = genres.map { it.id },
parentId = itemId,
recursive = true,
limit = 1,
sortBy = listOf(ItemSortBy.RANDOM),
fields = listOf(ItemFields.GENRES),
imageTypes = listOf(ImageType.BACKDROP),
imageTypeLimit = 1,
includeItemTypes = includeItemTypes,
genreIds = listOf(genre.id),
enableTotalRecordCount = false,
),
).content.items
.firstOrNull()
if (item != null) {
genreToUrl[genre.id] =
imageUrlService.getItemImageUrl(
itemId = item.id,
itemType = item.type,
seriesId = null,
useSeriesForPrimary = true,
imageType = ImageType.BACKDROP,
imageTags = item.imageTags.orEmpty(),
fillWidth = cardWidthPx,
cardWidthPx = cardWidthPx,
)
}
}
}
}.awaitAll()
val genresWithImages =
genres.map {
it.copy(
@ -171,11 +145,84 @@ class GenreViewModel
}
}
data class GenreCacheKey(
val userId: UUID?,
val parentId: UUID,
)
private val genreCache by lazy {
InMemoryKache<GenreCacheKey, Map<UUID, String?>>(8) {
expireAfterWriteDuration = 2.hours
}
}
suspend fun getGenreImageMap(
api: ApiClient,
userId: UUID?,
scope: CoroutineScope,
imageUrlService: ImageUrlService,
genres: List<UUID>,
parentId: UUID,
includeItemTypes: List<BaseItemKind>?,
cardWidthPx: Int?,
useCache: Boolean = true,
): Map<UUID, String?> {
val key = GenreCacheKey(userId, parentId)
if (useCache) {
genreCache.getIfAvailable(key)?.let {
Timber.v("Got cached entry")
return it
}
}
val genreToUrl = ConcurrentHashMap<UUID, String?>()
val semaphore = Semaphore(4)
genres
.map { genreId ->
scope.async(Dispatchers.IO) {
semaphore.withPermit {
val item =
GetItemsRequestHandler
.execute(
api,
GetItemsRequest(
userId = userId,
parentId = parentId,
recursive = true,
limit = 1,
sortBy = listOf(ItemSortBy.RANDOM),
fields = listOf(ItemFields.GENRES),
imageTypes = listOf(ImageType.BACKDROP),
imageTypeLimit = 1,
includeItemTypes = includeItemTypes,
genreIds = listOf(genreId),
enableTotalRecordCount = false,
),
).content.items
.firstOrNull()
if (item != null) {
genreToUrl[genreId] =
imageUrlService.getItemImageUrl(
itemId = item.id,
itemType = item.type,
seriesId = null,
useSeriesForPrimary = true,
imageType = ImageType.BACKDROP,
imageTags = item.imageTags.orEmpty(),
fillWidth = cardWidthPx,
)
}
}
}
}.awaitAll()
genreCache.put(key, genreToUrl)
return genreToUrl
}
@Stable
data class Genre(
val id: UUID,
val name: String,
val imageUrl: String?,
val color: Color,
) : CardGridItem {
override val gridId: String get() = id.toString()
override val playable: Boolean = false
@ -233,24 +280,13 @@ fun GenreCardGrid(
pager = genres,
onClickItem = { _, genre ->
viewModel.navigationManager.navigateTo(
Destination.FilteredCollection(
itemId = itemId,
filter =
CollectionFolderFilter(
nameOverride =
listOfNotNull(
genre.name,
item?.title,
).joinToString(" "),
filter =
GetItemsFilter(
genres = listOf(genre.id),
createGenreDestination(
genreId = genre.id,
genreName = genre.name,
parentId = itemId,
parentName = item?.title,
includeItemTypes = includeItemTypes,
),
useSavedLibraryDisplayInfo = false,
),
recursive = true,
),
)
},
onLongClickItem = { _, _ -> },

View file

@ -95,13 +95,13 @@ fun ItemGrid(
val items by viewModel.items.observeAsState(listOf())
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {

View file

@ -34,13 +34,14 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID
@ -100,13 +101,13 @@ abstract class RecommendedViewModel(
abstract fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
)
): HomeRowLoadingState
fun update(
@StringRes title: Int,
block: suspend () -> List<BaseItem>,
) {
viewModelScope.launch(Dispatchers.IO) {
): Deferred<HomeRowLoadingState> =
viewModelScope.async(Dispatchers.IO) {
val titleStr = context.getString(title)
val row =
try {
@ -115,9 +116,6 @@ abstract class RecommendedViewModel(
HomeRowLoadingState.Error(titleStr, null, ex)
}
update(title, row)
// TODO
loading.setValueOnMain(LoadingState.Success)
}
}
}
@ -142,18 +140,20 @@ fun RecommendedContent(
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {
var position by rememberPosition()
HomePageContent(
homeRows = rows,
position = position,
onClickItem = { _, item ->
viewModel.navigationManager.navigateTo(item.destination())
},
@ -163,7 +163,21 @@ fun RecommendedContent(
onClickPlay = { _, item ->
viewModel.navigationManager.navigateTo(Destination.Playback(item))
},
onFocusPosition = onFocusPosition,
onFocusPosition = {
position = it
val nonEmptyRowBefore =
rows
.subList(0, it.row)
.count {
it is HomeRowLoadingState.Success && it.items.isEmpty()
}
onFocusPosition?.invoke(
RowColumn(
it.row - nonEmptyRowBefore,
it.column,
),
)
},
showClock = preferences.appPreferences.interfacePreferences.showClock,
onUpdateBackdrop = viewModel::updateBackdrop,
modifier = modifier,

View file

@ -32,6 +32,8 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
@ -123,6 +125,8 @@ class RecommendedMovieViewModel
}
}
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
update(R.string.recently_released) {
val request =
GetItemsRequest(
@ -138,7 +142,7 @@ class RecommendedMovieViewModel
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}
}.also(jobs::add)
update(R.string.recently_added) {
val request =
@ -155,7 +159,7 @@ class RecommendedMovieViewModel
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}
}.also(jobs::add)
update(R.string.top_unwatched) {
val request =
@ -173,7 +177,7 @@ class RecommendedMovieViewModel
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
}
}.also(jobs::add)
viewModelScope.launch(Dispatchers.IO) {
try {
@ -204,6 +208,8 @@ class RecommendedMovieViewModel
}
update(R.string.suggestions, state)
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Failed to fetch suggestions")
update(
@ -216,19 +222,29 @@ class RecommendedMovieViewModel
}
}
// If the continue watching row is empty, then wait until the first successful row
// is loaded before telling the UI that the page is loaded
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
for (i in 0..<jobs.size) {
val result = jobs[i].await()
if (result.completed) {
Timber.v("First success")
loading.setValueOnMain(LoadingState.Success)
}
break
}
}
}
}
override fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
) {
): HomeRowLoadingState {
rows.update { current ->
current.toMutableList().apply { set(rowTitles[title]!!, row) }
}
return row
}
companion object {

View file

@ -33,6 +33,8 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
@ -170,6 +172,8 @@ class RecommendedTvShowViewModel
}
}
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
update(R.string.recently_released) {
val request =
GetItemsRequest(
@ -185,7 +189,7 @@ class RecommendedTvShowViewModel
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
}
}.also(jobs::add)
update(R.string.recently_added) {
val request =
@ -202,7 +206,7 @@ class RecommendedTvShowViewModel
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
}
}.also(jobs::add)
update(R.string.top_unwatched) {
val request =
@ -220,7 +224,7 @@ class RecommendedTvShowViewModel
enableTotalRecordCount = false,
)
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
}
}.also(jobs::add)
viewModelScope.launch(Dispatchers.IO) {
try {
@ -251,6 +255,8 @@ class RecommendedTvShowViewModel
}
update(R.string.suggestions, state)
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Failed to fetch suggestions")
update(
@ -264,18 +270,26 @@ class RecommendedTvShowViewModel
}
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
for (i in 0..<jobs.size) {
val result = jobs[i].await()
if (result is HomeRowLoadingState.Success) {
Timber.v("First success")
loading.setValueOnMain(LoadingState.Success)
}
break
}
}
}
}
override fun update(
@StringRes title: Int,
row: HomeRowLoadingState,
) {
): HomeRowLoadingState {
rows.update { current ->
current.toMutableList().apply { set(rowTitles[title]!!, row) }
}
return row
}
companion object {

View file

@ -4,6 +4,8 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
@ -24,6 +26,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@ -112,6 +115,7 @@ fun <T : CardGridItem> CardGrid(
},
columns: Int = 6,
spacing: Dp = 16.dp,
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
@ -269,6 +273,7 @@ fun <T : CardGridItem> CardGrid(
Box(
modifier = Modifier.weight(1f),
) {
CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) {
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
horizontalArrangement = Arrangement.spacedBy(spacing),
@ -366,6 +371,7 @@ fun <T : CardGridItem> CardGrid(
}
}
}
}
val context = LocalContext.current
val letters = context.getString(R.string.jump_letters)
// Letters

View file

@ -65,7 +65,7 @@ fun CollectionFolderPhotoAlbum(
} else {
item.destination(index)
}
viewModel.navigationManager.navigateTo(destination)
viewModel.navigateTo(destination)
},
itemId = itemId.toServerString(),
initialFilter = filter,

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Build
import android.util.Log
import android.view.Display
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
@ -32,11 +34,11 @@ import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -60,13 +62,19 @@ class DebugViewModel
constructor(
val serverRepository: ServerRepository,
val itemPlaybackDao: ItemPlaybackDao,
val refreshRateService: RefreshRateService,
val clientInfo: ClientInfo,
val deviceInfo: DeviceInfo,
) : ViewModel() {
val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf())
val logcat = MutableLiveData<List<LogcatLine>>(listOf())
val supportedModes by lazy {
val displayManager =
MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
display.supportedModes.orEmpty()
}
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.rowId?.let {
@ -260,7 +268,7 @@ fun DebugPage(
"Model: ${Build.MODEL}",
"API Level: ${Build.VERSION.SDK_INT}",
"Display Modes:",
*viewModel.refreshRateService.supportedDisplayModes,
*viewModel.supportedModes,
).forEach {
Text(
text = it.toString(),

View file

@ -113,13 +113,13 @@ fun DiscoverMovieDetails(
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {

View file

@ -73,7 +73,8 @@ class DiscoverMovieViewModel
val canCancelRequest = MutableStateFlow(false)
val userConfig = seerrServerRepository.current.map { it?.config }
val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
val request4kEnabled =
seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
init {
init()

View file

@ -105,13 +105,13 @@ fun DiscoverSeriesDetails(
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {

View file

@ -102,13 +102,13 @@ fun EpisodeDetails(
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {

View file

@ -130,13 +130,13 @@ fun MovieDetails(
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {

View file

@ -0,0 +1,248 @@
package com.github.damontecres.wholphin.ui.detail.search
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.focus.FocusDirection
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.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
import com.github.damontecres.wholphin.ui.main.SearchResult
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
fun SearchForContent(
searchType: BaseItemKind,
onClick: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchForViewModel = hiltViewModel(key = searchType.serialName),
) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val state by viewModel.state.collectAsState()
var query by rememberSaveable { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() }
val focusRequester = remember { FocusRequester() }
var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
LifecycleResumeEffect(Unit) {
onPauseOrDispose {
viewModel.voiceInputManager.stopListening()
}
}
fun triggerImmediateSearch(searchQuery: String) {
immediateSearchQuery = searchQuery
viewModel.search(searchType, searchQuery)
}
LaunchedEffect(query) {
when {
immediateSearchQuery == query -> {
immediateSearchQuery = null
}
else -> {
delay(750L)
viewModel.search(searchType, query)
}
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth(),
) {
var isSearchActive by remember { mutableStateOf(false) }
var isTextFieldFocused by remember { mutableStateOf(false) }
val textFieldFocusRequester = remember { FocusRequester() }
BackHandler(isTextFieldFocused) {
when {
isSearchActive -> {
isSearchActive = false
keyboardController?.hide()
}
else -> {
focusManager.moveFocus(FocusDirection.Next)
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.focusGroup()
.focusRestorer(textFieldFocusRequester)
.focusRequester(searchFocusRequester),
) {
VoiceSearchButton(
onSpeechResult = { spokenText ->
query = spokenText
triggerImmediateSearch(spokenText)
},
voiceInputManager = viewModel.voiceInputManager,
)
SearchEditTextBox(
value = query,
onValueChange = {
isSearchActive = true
query = it
},
onSearchClick = { triggerImmediateSearch(query) },
readOnly = !isSearchActive,
modifier =
Modifier
.focusRequester(textFieldFocusRequester)
.onFocusChanged { state ->
isTextFieldFocused = state.isFocused
if (!state.isFocused) isSearchActive = false
}.onPreviewKeyEvent { event ->
val isActivationKey =
event.key in listOf(Key.DirectionCenter, Key.Enter)
if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) {
isSearchActive = true
keyboardController?.show()
true
} else {
false
}
},
)
}
}
when (val st = state.results) {
is SearchResult.Error -> {
ErrorMessage("Error", st.ex)
}
SearchResult.NoQuery -> {
// no-op
}
SearchResult.Searching -> {
Text(
text = stringResource(R.string.searching),
)
}
is SearchResult.SuccessSeerr -> {
Text(
text = "Not supported",
color = MaterialTheme.colorScheme.error,
)
}
is SearchResult.Success -> {
if (st.items.isEmpty()) {
Text(
text = stringResource(R.string.no_results),
)
} else {
val titleRes =
remember {
when (searchType) {
BaseItemKind.BOX_SET -> R.string.collections
BaseItemKind.PLAYLIST -> R.string.playlists
else -> null
}
}
ItemRow(
title = titleRes?.let { stringResource(it) } ?: "",
items = st.items,
onClickItem = { _, item -> onClick.invoke(item) },
onLongClickItem = { _, _ -> },
modifier = Modifier.focusRequester(focusRequester),
cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = {
onClick.invoke()
},
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
modifier = mod,
)
},
)
}
}
}
}
}
@Composable
fun SearchForDialog(
onDismissRequest: () -> Unit,
searchType: BaseItemKind,
onClick: (BaseItem) -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
SearchForContent(
searchType = searchType,
onClick = onClick,
modifier =
Modifier
.padding(8.dp)
.fillMaxWidth(.8f)
.fillMaxHeight(.66f),
)
}
}

View file

@ -0,0 +1,71 @@
package com.github.damontecres.wholphin.ui.detail.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.VoiceInputManager
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.SearchResult
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class SearchForViewModel
@Inject
constructor(
private val api: ApiClient,
private val serverRepository: ServerRepository,
val navigationManager: NavigationManager,
val voiceInputManager: VoiceInputManager,
) : ViewModel() {
val state = MutableStateFlow(SearchForState())
init {
state.value = SearchForState()
}
fun search(
searchType: BaseItemKind,
query: String,
) {
viewModelScope.launchIO {
if (state.value.query != query) {
if (query.isBlank()) {
state.update { SearchForState(query, SearchResult.NoQuery) }
return@launchIO
}
state.update { SearchForState(query, SearchResult.Searching) }
try {
val request =
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
searchTerm = query,
includeItemTypes = listOf(searchType),
recursive = true,
fields = SlimItemFields,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
state.update { SearchForState(query, SearchResult.Success(pager)) }
} catch (ex: Exception) {
Timber.e(ex)
state.update { SearchForState(query, SearchResult.Error(ex)) }
}
}
}
}
}
data class SearchForState(
val query: String = "",
val results: SearchResult = SearchResult.NoQuery,
)

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusState
@ -31,17 +32,17 @@ fun FocusedEpisodeHeader(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
EpisodeName(dto, modifier = Modifier)
EpisodeName(dto, modifier = Modifier.padding(start = 8.dp))
ep?.ui?.quickDetails?.let {
QuickDetails(it, ep.timeRemainingOrRuntime)
QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp))
}
if (dto != null) {
VideoStreamDetails(
chosenStreams = chosenStreams,
numberOfVersions = dto.mediaSourceCount ?: 0,
modifier = Modifier,
modifier = Modifier.padding(start = 8.dp),
)
}
OverviewText(

View file

@ -120,13 +120,13 @@ fun SeriesDetails(
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {
@ -621,7 +621,7 @@ fun SeriesDetailsHeader(
) {
QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
dto.genres?.letNotEmpty {
GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp))
GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp))
}
dto.overview?.let { overview ->
OverviewText(

View file

@ -148,13 +148,13 @@ fun SeriesOverview(
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {

View file

@ -134,7 +134,7 @@ fun SeriesOverviewContent(
.onFocusChanged { pageHasFocus = it.hasFocus },
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier =
Modifier
.focusGroup()
@ -159,9 +159,10 @@ fun SeriesOverviewContent(
Modifier
.focusRequester(tabRowFocusRequester)
.padding(paddingValues)
.padding(bottom = 4.dp)
.fillMaxWidth(),
)
SeriesName(series.name, Modifier)
SeriesName(series.name, Modifier.padding(start = 8.dp))
FocusedEpisodeHeader(
preferences = preferences,
ep = focusedEpisode,
@ -266,6 +267,7 @@ fun SeriesOverviewContent(
},
interactionSource = interactionSource,
cardHeight = 120.dp,
useSeriesForPrimary = false,
)
}
}
@ -294,8 +296,8 @@ fun SeriesOverviewContent(
},
modifier =
Modifier
.fillMaxWidth()
.padding(start = 16.dp),
.padding(top = 4.dp)
.fillMaxWidth(),
)
}
}

View file

@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.discover
import android.content.Context
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -23,6 +26,7 @@ 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.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -47,6 +51,7 @@ import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.DataLoadingState
import com.google.common.cache.CacheBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
@ -186,6 +191,7 @@ data class DiscoverState(
val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY,
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SeerrDiscoverPage(
preferences: UserPreferences,
@ -249,6 +255,16 @@ fun SeerrDiscoverPage(
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
.fillMaxHeight(.25f),
)
val density = LocalDensity.current
val spaceAbovePx =
with(density) {
// The size of the row titles & spacing
50.dp.toPx()
}
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
CompositionLocalProvider(
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
@ -258,6 +274,7 @@ fun SeerrDiscoverPage(
.fillMaxSize(),
) {
itemsIndexed(rows) { rowIndex, row ->
CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
DiscoverRow(
row = row,
onClickItem = { index, item ->
@ -275,6 +292,8 @@ fun SeerrDiscoverPage(
}
}
}
}
}
@Composable
fun DiscoverRow(

View file

@ -64,7 +64,7 @@ class SeerrRequestsViewModel
viewModelScope.launchIO {
backdropService.clearBackdrop()
}
seerrServerRepository.current
seerrServerRepository.connection
.onEach { user ->
state.update { it.copy(requests = DataLoadingState.Loading) }
if (user != null) {

View file

@ -1,8 +1,8 @@
package com.github.damontecres.wholphin.ui.main
import androidx.compose.foundation.background
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -16,10 +16,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@ -31,9 +34,9 @@ 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.Color
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@ -44,16 +47,19 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
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.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeName
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
@ -67,8 +73,10 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.delay
@ -89,34 +97,37 @@ fun HomePage(
LaunchedEffect(Unit) {
viewModel.init()
}
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading)
val watchingRows by viewModel.watchingRows.observeAsState(listOf())
val latestRows by viewModel.latestRows.observeAsState(listOf())
val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows }
val state by viewModel.state.collectAsState()
val loading = state.loadingState
val refreshing = state.refreshState
val homeRows = state.homeRows
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state)
ErrorMessage(state, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage()
LoadingPage(modifier)
}
LoadingState.Success -> {
var dialog by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var position by rememberPosition()
HomePageContent(
homeRows = homeRows,
onClickItem = { position, item ->
position = position,
onFocusPosition = { position = it },
onClickItem = { clickedPosition, item ->
position = clickedPosition
viewModel.navigationManager.navigateTo(item.destination())
},
onLongClickItem = { position, item ->
onLongClickItem = { clickedPosition, item ->
position = clickedPosition
val dialogItems =
buildMoreDialogItemsForHome(
context = context,
@ -183,27 +194,31 @@ fun HomePage(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomePageContent(
homeRows: List<HomeRowLoadingState>,
position: RowColumn,
onFocusPosition: (RowColumn) -> Unit,
onClickItem: (RowColumn, BaseItem) -> Unit,
onLongClickItem: (RowColumn, BaseItem) -> Unit,
onClickPlay: (RowColumn, BaseItem) -> Unit,
showClock: Boolean,
onUpdateBackdrop: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null,
listState: LazyListState = rememberLazyListState(),
takeFocus: Boolean = true,
showEmptyRows: Boolean = false,
) {
var position by rememberPosition()
val focusedItem =
position.let {
(homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column)
}
val listState = rememberLazyListState()
val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } }
var firstFocused by remember { mutableStateOf(false) }
if (takeFocus) {
LaunchedEffect(homeRows) {
if (!firstFocused && homeRows.isNotEmpty()) {
if (position.row >= 0) {
@ -223,6 +238,7 @@ fun HomePageContent(
}
}
}
}
LaunchedEffect(position) {
if (position.row >= 0) {
listState.animateScrollToItem(position.row)
@ -240,6 +256,16 @@ fun HomePageContent(
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f),
)
val density = LocalDensity.current
val spaceAbovePx =
with(density) {
// The size of the row titles & spacing
50.dp.toPx()
}
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
CompositionLocalProvider(
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
) {
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -252,60 +278,32 @@ fun HomePageContent(
.focusRestorer(),
) {
itemsIndexed(homeRows) { rowIndex, row ->
CompositionLocalProvider(
LocalBringIntoViewSpec provides defaultBringIntoViewSpec,
) {
when (val r = row) {
is HomeRowLoadingState.Loading,
is HomeRowLoadingState.Pending,
-> {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
FocusableItemRow(
title = r.title,
subtitle = stringResource(R.string.loading),
modifier = Modifier.animateItem(),
) {
Text(
text = r.title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = stringResource(R.string.loading),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
is HomeRowLoadingState.Error -> {
var focused by remember { mutableStateOf(false) }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.onFocusChanged {
focused = it.isFocused
}.focusable()
.background(
if (focused) {
// Just so the user can tell it has focus
MaterialTheme.colorScheme.border.copy(alpha = .25f)
} else {
Color.Unspecified
},
).animateItem(),
) {
Text(
text = r.title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
FocusableItemRow(
title = r.title,
subtitle = r.localizedMessage,
isError = true,
modifier = Modifier.animateItem(),
)
Text(
text = r.localizedMessage,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
is HomeRowLoadingState.Success -> {
if (row.items.isNotEmpty()) {
val viewOptions = row.viewOptions
ItemRow(
title = row.title,
items = row.items,
@ -313,7 +311,10 @@ fun HomePageContent(
onClickItem.invoke(RowColumn(rowIndex, index), item)
},
onLongClickItem = { index, item ->
onLongClickItem.invoke(RowColumn(rowIndex, index), item)
onLongClickItem.invoke(
RowColumn(rowIndex, index),
item,
)
},
modifier =
Modifier
@ -321,53 +322,44 @@ fun HomePageContent(
.focusGroup()
.focusRequester(rowFocusRequesters[rowIndex])
.animateItem(),
horizontalPadding = viewOptions.spacing.dp,
cardContent = { index, item, cardModifier, onClick, onLongClick ->
BannerCard(
name = item?.data?.seriesName ?: item?.name,
HomePageCardContent(
index = index,
item = item,
aspectRatio = AspectRatios.TALL,
cornerText = item?.ui?.episodeUnplayedCornerText,
played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false,
playPercent =
item?.data?.userData?.playedPercentage
?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
viewOptions = viewOptions,
modifier =
cardModifier
.onFocusChanged {
if (it.isFocused) {
position = RowColumn(rowIndex, index)
// item?.let(onUpdateBackdrop)
}
if (it.isFocused && onFocusPosition != null) {
val nonEmptyRowBefore =
homeRows
.subList(0, rowIndex)
.count {
it is HomeRowLoadingState.Success && it.items.isEmpty()
}
onFocusPosition.invoke(
RowColumn(
rowIndex - nonEmptyRowBefore,
index,
),
onFocusPosition?.invoke(
RowColumn(rowIndex, index),
)
}
}.onKeyEvent {
if (isPlayKeyUp(it) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(position, item)
onClickPlay.invoke(
position,
item,
)
return@onKeyEvent true
}
return@onKeyEvent false
},
interactionSource = null,
cardHeight = Cards.height2x3,
)
},
)
} else if (showEmptyRows) {
FocusableItemRow(
title = r.title,
subtitle = stringResource(R.string.no_results),
modifier = Modifier.animateItem(),
)
}
}
}
}
}
@ -406,7 +398,7 @@ fun HomePageHeader(
subtitle = if (isEpisode) dto?.name else null,
overview = dto?.overview,
overviewTwoLines = isEpisode,
quickDetails = item?.ui?.quickDetails,
quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
timeRemaining = item?.timeRemainingOrRuntime,
modifier = modifier,
)
@ -467,3 +459,94 @@ fun HomePageHeader(
}
}
}
@Composable
fun HomePageCardContent(
index: Int,
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
viewOptions: HomeRowViewOptions,
modifier: Modifier,
) {
when (item?.type) {
BaseItemKind.GENRE -> {
GenreCard(
genreId = item.id,
name = item.name,
imageUrl = item.imageUrlOverride,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier.height(viewOptions.heightDp.dp),
)
}
else -> {
val imageType =
remember(item, viewOptions) {
if (item?.type == BaseItemKind.EPISODE) {
viewOptions.episodeImageType.imageType
} else {
viewOptions.imageType.imageType
}
}
val ratio =
remember(item, viewOptions) {
if (item?.type == BaseItemKind.EPISODE) {
viewOptions.episodeAspectRatio.ratio
} else {
viewOptions.aspectRatio.ratio
}
}
val scale =
remember(item, viewOptions) {
if (item?.type == BaseItemKind.EPISODE) {
viewOptions.episodeContentScale.scale
} else {
viewOptions.contentScale.scale
}
}
if (viewOptions.showTitles) {
BannerCardWithTitle(
title = item?.title,
subtitle = item?.subtitle,
item = item,
aspectRatio = ratio,
imageType = imageType,
imageContentScale = scale,
cornerText = item?.ui?.episodeUnplayedCornerText,
played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false,
playPercent =
item?.data?.userData?.playedPercentage
?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
cardHeight = viewOptions.heightDp.dp,
useSeriesForPrimary = viewOptions.useSeries,
)
} else {
BannerCard(
name = item?.data?.seriesName ?: item?.name,
item = item,
aspectRatio = ratio,
imageType = imageType,
imageContentScale = scale,
cornerText = item?.ui?.episodeUnplayedCornerText,
played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false,
playPercent =
item?.data?.userData?.playedPercentage
?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
interactionSource = null,
cardHeight = viewOptions.heightDp.dp,
useSeriesForPrimary = viewOptions.useSeries,
)
}
}
}
}

View file

@ -1,37 +1,39 @@
package com.github.damontecres.wholphin.ui.main
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
@ -41,115 +43,122 @@ class HomeViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
val api: ApiClient,
val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
val navDrawerItemRepository: NavDrawerItemRepository,
val mediaReportService: MediaReportService,
private val navDrawerService: NavDrawerService,
private val homeSettingsService: HomeSettingsService,
private val favoriteWatchManager: FavoriteWatchManager,
private val datePlayedService: DatePlayedService,
private val latestNextUpService: LatestNextUpService,
private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService,
) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
val watchingRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
val latestRows = MutableLiveData<List<HomeRowLoadingState>>(listOf())
private lateinit var preferences: UserPreferences
private val _state = MutableStateFlow(HomeState.EMPTY)
val state: StateFlow<HomeState> = _state
init {
datePlayedService.invalidateAll()
init()
// init()
}
fun init() {
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loadingState,
"Error loading home page",
),
) {
viewModelScope.launchIO {
Timber.d("init HomeViewModel")
val reload = loadingState.value != LoadingState.Success
if (reload) {
loadingState.setValueOnMain(LoadingState.Loading)
}
refreshState.setValueOnMain(LoadingState.Loading)
this@HomeViewModel.preferences = userPreferencesService.getCurrent()
val prefs = preferences.appPreferences.homePagePreferences
val limit = prefs.maxItemsPerRow
if (reload) {
backdropService.clearBackdrop()
}
try {
val preferences = userPreferencesService.getCurrent()
val prefs = preferences.appPreferences.homePagePreferences
serverRepository.currentUserDto.value?.let { userDto ->
val includedIds =
navDrawerItemRepository
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
.filter { it is ServerNavDrawerItem }
.map { (it as ServerNavDrawerItem).itemId }
val resume = latestNextUpService.getResume(userDto.id, limit, true)
val nextUp =
latestNextUpService.getNextUp(
userDto.id,
limit,
prefs.enableRewatchingNextUp,
false,
prefs.maxDaysNextUp,
)
val watching =
buildList {
if (prefs.combineContinueNext) {
val items = latestNextUpService.buildCombined(resume, nextUp)
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.continue_watching),
items = items,
),
)
} else {
if (resume.isNotEmpty()) {
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.continue_watching),
items = resume,
),
)
val libraries =
navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val settings =
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
val state = state.value
// Refreshing if a load has already occurred and the rows haven't significantly changed
val refresh =
state.loadingState == LoadingState.Success && state.settings == settings
Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}")
_state.update { it.copy(settings = settings) }
val semaphore = Semaphore(4)
val watchingRowIndexes =
settings.rows
.mapIndexedNotNull { index, row ->
if (isWatchingRow(row.config)) index else null
}
if (nextUp.isNotEmpty()) {
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.next_up),
items = nextUp,
),
val deferred =
settings.rows
// Load the watching rows first
.sortedByDescending { isWatchingRow(it.config) }
.map { row ->
viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit {
Timber.v("Fetching row: %s", row)
try {
homeSettingsService.fetchDataForRow(
row = row.config,
scope = viewModelScope,
prefs = prefs,
userDto = userDto,
libraries = libraries,
limit = prefs.maxItemsPerRow,
isRefresh = refresh,
)
} catch (ex: Exception) {
Timber.e(ex, "Error on row %s", row)
HomeRowLoadingState.Error(row.title, exception = ex)
}
}
}
}
val latest = latestNextUpService.getLatest(userDto, limit, includedIds)
val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
withContext(Dispatchers.Main) {
this@HomeViewModel.watchingRows.value = watching
if (reload) {
this@HomeViewModel.latestRows.value = pendingLatest
if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) {
// Replace watching rows first
Timber.v("Refreshing rows: %s", watchingRowIndexes)
val rows =
deferred
.filterIndexed { index, _ -> index in watchingRowIndexes }
.awaitAll()
_state.update {
val newRows =
it.homeRows.toMutableList().apply {
rows.forEachIndexed { index, row ->
set(watchingRowIndexes[index], row)
}
loadingState.value = LoadingState.Success
}
refreshState.setValueOnMain(LoadingState.Success)
val loadedLatest = latestNextUpService.loadLatest(latest)
this@HomeViewModel.latestRows.setValueOnMain(loadedLatest)
it.copy(
loadingState = LoadingState.Success,
homeRows = newRows,
)
}
}
val rows =
deferred
.awaitAll()
.filter {
// Include only errors & non-empty successes
it is HomeRowLoadingState.Error ||
(it is HomeRowLoadingState.Success && it.items.isNotEmpty())
}
Timber.v("Got all rows")
_state.update {
it.copy(
loadingState = LoadingState.Success,
refreshState = LoadingState.Success,
homeRows = rows,
)
}
}
} catch (ex: Exception) {
Timber.e(ex)
if (!reload) {
loadingState.setValueOnMain(LoadingState.Error(ex))
} else {
Timber.e(ex, "Exception during home page loading")
if (state.value.loadingState == LoadingState.Success) {
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
} else {
_state.update {
it.copy(loadingState = LoadingState.Error(ex))
}
}
}
}
@ -182,16 +191,27 @@ class HomeViewModel
}
}
val supportedLatestCollectionTypes =
setOf(
CollectionType.MOVIES,
CollectionType.TVSHOWS,
CollectionType.HOMEVIDEOS,
// Exclude Live TV because a recording folder view will be used instead
null, // Recordings & mixed collection types
data class HomeState(
val loadingState: LoadingState,
val refreshState: LoadingState,
val homeRows: List<HomeRowLoadingState>,
val settings: HomePageResolvedSettings,
) {
companion object {
val EMPTY =
HomeState(
LoadingState.Pending,
LoadingState.Pending,
listOf(),
HomePageResolvedSettings.EMPTY,
)
}
}
data class LatestData(
val title: String,
val request: GetLatestMediaRequest,
)
/**
* Whether a row is a "is watching" type
*/
private fun isWatchingRow(row: HomeRowConfig) =
row is HomeRowConfig.ContinueWatching ||
row is HomeRowConfig.NextUp ||
row is HomeRowConfig.ContinueWatchingCombined

View file

@ -0,0 +1,132 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.focus.focusRestorer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ListItem
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.services.SuggestionsWorker
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.CollectionType
@Composable
fun HomeLibraryRowTypeList(
library: Library,
onClick: (LibraryRowType) -> Unit,
modifier: Modifier,
firstFocus: FocusRequester = remember { FocusRequester() },
) {
val items = remember(library) { getSupportedRowTypes(library) }
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
Column(modifier = modifier) {
TitleText(stringResource(R.string.add_row_for, library.name))
LazyColumn(
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.fillMaxHeight()
.focusRestorer(firstFocus),
) {
itemsIndexed(items) { index, rowType ->
ListItem(
selected = false,
headlineContent = {
Text(
text = stringResource(rowType.stringId),
)
},
onClick = { onClick.invoke(rowType) },
modifier =
Modifier
.fillMaxWidth()
.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
)
}
}
}
}
fun getSupportedRowTypes(library: Library): List<LibraryRowType> {
val supportsSuggestions = SuggestionsWorker.getTypeForCollection(library.collectionType) != null
return when {
library.isRecordingFolder -> {
listOf(
LibraryRowType.RECENTLY_RECORDED,
LibraryRowType.GENRES,
)
}
library.collectionType == CollectionType.LIVETV -> {
listOf(
LibraryRowType.TV_CHANNELS,
LibraryRowType.TV_PROGRAMS,
)
}
supportsSuggestions -> {
listOf(
LibraryRowType.RECENTLY_ADDED,
LibraryRowType.RECENTLY_RELEASED,
LibraryRowType.SUGGESTIONS,
LibraryRowType.GENRES,
)
}
library.collectionType == CollectionType.BOXSETS -> {
listOf(
LibraryRowType.RECENTLY_ADDED,
LibraryRowType.RECENTLY_RELEASED,
LibraryRowType.GENRES,
LibraryRowType.COLLECTION,
)
}
library.collectionType == CollectionType.PLAYLISTS -> {
listOf(
LibraryRowType.RECENTLY_ADDED,
LibraryRowType.RECENTLY_RELEASED,
LibraryRowType.GENRES,
LibraryRowType.PLAYLIST,
)
}
else -> {
listOf(
LibraryRowType.RECENTLY_ADDED,
LibraryRowType.RECENTLY_RELEASED,
LibraryRowType.GENRES,
)
}
}
}
enum class LibraryRowType(
@param:StringRes val stringId: Int,
) {
RECENTLY_ADDED(R.string.recently_added),
RECENTLY_RELEASED(R.string.recently_released),
SUGGESTIONS(R.string.suggestions),
GENRES(R.string.genres),
TV_CHANNELS(R.string.channels),
TV_PROGRAMS(R.string.live_tv),
RECENTLY_RECORDED(R.string.recently_recorded),
COLLECTION(R.string.collection),
PLAYLIST(R.string.playlist),
}

View file

@ -0,0 +1,251 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.focus.focusRestorer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.CollectionType
data class HomeRowPresets(
val continueWatching: HomeRowViewOptions,
val movieLibrary: HomeRowViewOptions,
val tvLibrary: HomeRowViewOptions,
val videoLibrary: HomeRowViewOptions,
val photoLibrary: HomeRowViewOptions,
val playlist: HomeRowViewOptions,
val liveTv: HomeRowViewOptions,
val genreSize: Int,
) {
fun getByCollectionType(collectionType: CollectionType): HomeRowViewOptions =
when (collectionType) {
CollectionType.MOVIES -> movieLibrary
CollectionType.TVSHOWS -> tvLibrary
CollectionType.MUSICVIDEOS -> videoLibrary
CollectionType.TRAILERS -> videoLibrary
CollectionType.HOMEVIDEOS -> videoLibrary
CollectionType.BOXSETS -> movieLibrary
CollectionType.PHOTOS -> photoLibrary
CollectionType.LIVETV -> liveTv
CollectionType.UNKNOWN,
CollectionType.MUSIC,
CollectionType.BOOKS,
CollectionType.PLAYLISTS,
CollectionType.FOLDERS,
-> HomeRowViewOptions()
}
companion object {
val WholphinDefault by lazy {
HomeRowPresets(
continueWatching = HomeRowViewOptions(),
movieLibrary = HomeRowViewOptions(),
tvLibrary = HomeRowViewOptions(),
videoLibrary =
HomeRowViewOptions(
aspectRatio = AspectRatio.WIDE,
),
photoLibrary =
HomeRowViewOptions(
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP,
),
playlist =
HomeRowViewOptions(
aspectRatio = AspectRatio.SQUARE,
contentScale = PrefContentScale.FIT,
),
liveTv = HomeRowViewOptions.liveTvDefault,
genreSize = HomeRowViewOptions.genreDefault.heightDp,
)
}
val WholphinCompact by lazy {
val height = 148
val epHeight = 100
HomeRowPresets(
continueWatching =
HomeRowViewOptions(
heightDp = height,
),
movieLibrary =
HomeRowViewOptions(
heightDp = height,
),
tvLibrary =
HomeRowViewOptions(
heightDp = height,
),
videoLibrary =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.WIDE,
),
photoLibrary =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP,
),
playlist =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.SQUARE,
contentScale = PrefContentScale.FIT,
),
liveTv = HomeRowViewOptions.liveTvDefault,
genreSize = epHeight,
)
}
val SeriesThumbs by lazy {
val height = 148
val epHeight = 100
HomeRowPresets(
continueWatching =
HomeRowViewOptions(
heightDp = epHeight,
imageType = ViewOptionImageType.THUMB,
aspectRatio = AspectRatio.WIDE,
useSeries = true,
episodeImageType = ViewOptionImageType.THUMB,
episodeAspectRatio = AspectRatio.WIDE,
),
movieLibrary =
HomeRowViewOptions(
heightDp = height,
),
tvLibrary =
HomeRowViewOptions(
heightDp = height,
),
videoLibrary =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.WIDE,
),
photoLibrary =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP,
),
playlist =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.SQUARE,
contentScale = PrefContentScale.FIT,
),
liveTv = HomeRowViewOptions.liveTvDefault,
genreSize = epHeight,
)
}
val EpisodeThumbnails by lazy {
val height = 148
val epHeight = 100
HomeRowPresets(
continueWatching =
HomeRowViewOptions(
heightDp = epHeight,
imageType = ViewOptionImageType.THUMB,
aspectRatio = AspectRatio.WIDE,
showTitles = true,
useSeries = false,
episodeImageType = ViewOptionImageType.PRIMARY,
episodeAspectRatio = AspectRatio.WIDE,
),
movieLibrary =
HomeRowViewOptions(
heightDp = height,
),
tvLibrary =
HomeRowViewOptions(
heightDp = height,
),
videoLibrary =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.WIDE,
),
photoLibrary =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.CROP,
),
playlist =
HomeRowViewOptions(
heightDp = epHeight,
aspectRatio = AspectRatio.SQUARE,
contentScale = PrefContentScale.FIT,
),
liveTv = HomeRowViewOptions.liveTvDefault,
genreSize = epHeight,
)
}
}
}
@Composable
fun HomeRowPresetsContent(
onApply: (HomeRowPresets) -> Unit,
modifier: Modifier = Modifier,
) {
val presets =
listOf(
stringResource(R.string.display_preset_default) to HomeRowPresets.WholphinDefault,
stringResource(R.string.display_preset_compact) to HomeRowPresets.WholphinCompact,
stringResource(R.string.display_preset_series_thumb) to HomeRowPresets.SeriesThumbs,
stringResource(R.string.display_preset_episode_thumbnails) to HomeRowPresets.EpisodeThumbnails,
)
val focusRequesters = remember { List(presets.size) { FocusRequester() } }
LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() }
Column(modifier = modifier) {
TitleText(stringResource(R.string.display_presets))
LazyColumn(
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.fillMaxHeight()
.focusRestorer(focusRequesters[0]),
) {
itemsIndexed(presets) { index, (title, preset) ->
HomeSettingsListItem(
selected = false,
headlineText = title,
onClick = {
onApply.invoke(preset)
},
modifier = Modifier.focusRequester(focusRequesters[index]),
)
}
}
}
}

View file

@ -0,0 +1,307 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.res.stringResource
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
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.Cards
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun HomeRowSettings(
title: String,
preferenceOptions: List<PreferenceGroup<HomeRowViewOptions>>,
viewOptions: HomeRowViewOptions,
onViewOptionsChange: (HomeRowViewOptions) -> Unit,
onApplyApplyAll: () -> Unit,
modifier: Modifier = Modifier,
defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) {
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
Column(modifier = modifier) {
TitleText(title)
LazyColumn {
preferenceOptions.forEachIndexed { groupIndex, prefGroup ->
if (preferenceOptions.size > 1) {
item {
TitleText(stringResource(prefGroup.title))
}
}
itemsIndexed(prefGroup.preferences) { index, pref ->
pref as AppPreference<HomeRowViewOptions, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(viewOptions)
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(viewOptions, newValue))
},
interactionSource = interactionSource,
onClickPreference = { pref ->
when (pref) {
Options.ViewOptionsReset -> {
onViewOptionsChange.invoke(defaultViewOptions)
}
Options.ViewOptionsApplyAll -> {
onApplyApplyAll.invoke()
}
Options.ViewOptionsUseThumb -> {
onViewOptionsChange.invoke(
viewOptions.copy(
heightDp = Cards.HEIGHT_EPISODE,
spacing = 20,
imageType = ViewOptionImageType.THUMB,
aspectRatio = AspectRatio.WIDE,
contentScale = PrefContentScale.FIT,
episodeImageType = ViewOptionImageType.THUMB,
episodeAspectRatio = AspectRatio.WIDE,
episodeContentScale = PrefContentScale.FIT,
),
)
}
}
},
modifier =
Modifier
.ifElse(
groupIndex == 0 && index == 0,
Modifier.focusRequester(firstFocus),
),
)
}
}
}
}
}
internal object Options {
val ViewOptionsCardHeight =
AppSliderPreference<HomeRowViewOptions>(
title = R.string.height,
defaultValue = Cards.HEIGHT_2X3_DP.toLong(),
min = 64L,
max = Cards.HEIGHT_2X3_DP + 64L,
interval = 4,
getter = { it.heightDp.toLong() },
setter = { prefs, value -> prefs.copy(heightDp = value.toInt()) },
)
val ViewOptionsSpacing =
AppSliderPreference<HomeRowViewOptions>(
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<HomeRowViewOptions, PrefContentScale>(
title = R.string.global_content_scale,
defaultValue = PrefContentScale.FIT,
displayValues = R.array.content_scale,
getter = { it.contentScale },
setter = { viewOptions, value -> viewOptions.copy(contentScale = value) },
indexToValue = { PrefContentScale.forNumber(it) },
valueToIndex = { it.number },
)
val ViewOptionsAspectRatio =
AppChoicePreference<HomeRowViewOptions, AspectRatio>(
title = R.string.aspect_ratio,
defaultValue = AspectRatio.TALL,
displayValues = R.array.aspect_ratios,
getter = { it.aspectRatio },
setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) },
indexToValue = { AspectRatio.entries[it] },
valueToIndex = { it.ordinal },
)
val ViewOptionsShowTitles =
AppSwitchPreference<HomeRowViewOptions>(
title = R.string.show_titles,
defaultValue = true,
getter = { it.showTitles },
setter = { vo, value -> vo.copy(showTitles = value) },
)
val ViewOptionsUseSeries =
AppSwitchPreference<HomeRowViewOptions>(
title = R.string.use_series,
defaultValue = true,
getter = { it.useSeries },
setter = { vo, value -> vo.copy(useSeries = value) },
)
val ViewOptionsImageType =
AppChoicePreference<HomeRowViewOptions, ViewOptionImageType>(
title = R.string.image_type,
defaultValue = ViewOptionImageType.PRIMARY,
displayValues = R.array.image_types,
getter = { it.imageType },
setter = { viewOptions, value ->
val aspectRatio =
when (value) {
ViewOptionImageType.PRIMARY -> AspectRatio.TALL
ViewOptionImageType.THUMB -> AspectRatio.WIDE
}
viewOptions.copy(imageType = value, aspectRatio = aspectRatio)
},
indexToValue = { ViewOptionImageType.entries[it] },
valueToIndex = { it.ordinal },
)
val ViewOptionsApplyAll =
AppClickablePreference<HomeRowViewOptions>(
title = R.string.apply_all_rows,
)
val ViewOptionsReset =
AppClickablePreference<HomeRowViewOptions>(
title = R.string.reset,
)
val ViewOptionsUseThumb =
AppClickablePreference<HomeRowViewOptions>(
title = R.string.use_thumb_images,
)
val ViewOptionsEpisodeContentScale =
AppChoicePreference<HomeRowViewOptions, PrefContentScale>(
title = R.string.global_content_scale,
defaultValue = PrefContentScale.FIT,
displayValues = R.array.content_scale,
getter = { it.episodeContentScale },
setter = { viewOptions, value -> viewOptions.copy(episodeContentScale = value) },
indexToValue = { PrefContentScale.forNumber(it) },
valueToIndex = { it.number },
)
val ViewOptionsEpisodeAspectRatio =
AppChoicePreference<HomeRowViewOptions, AspectRatio>(
title = R.string.aspect_ratio,
defaultValue = AspectRatio.TALL,
displayValues = R.array.aspect_ratios,
getter = { it.episodeAspectRatio },
setter = { viewOptions, value -> viewOptions.copy(episodeAspectRatio = value) },
indexToValue = { AspectRatio.entries[it] },
valueToIndex = { it.ordinal },
)
val ViewOptionsEpisodeImageType =
AppChoicePreference<HomeRowViewOptions, ViewOptionImageType>(
title = R.string.image_type,
defaultValue = ViewOptionImageType.PRIMARY,
displayValues = R.array.image_types,
getter = { it.episodeImageType },
setter = { viewOptions, value ->
val aspectRatio =
when (value) {
ViewOptionImageType.PRIMARY -> AspectRatio.TALL
ViewOptionImageType.THUMB -> AspectRatio.WIDE
}
viewOptions.copy(episodeImageType = value, episodeAspectRatio = aspectRatio)
},
indexToValue = { ViewOptionImageType.entries[it] },
valueToIndex = { it.ordinal },
)
val OPTIONS =
listOf(
PreferenceGroup(
title = R.string.general,
preferences =
listOf(
ViewOptionsCardHeight,
ViewOptionsSpacing,
ViewOptionsShowTitles,
ViewOptionsImageType,
ViewOptionsAspectRatio,
ViewOptionsContentScale,
ViewOptionsUseSeries,
),
),
PreferenceGroup(
title = R.string.more,
preferences =
listOf(
// ViewOptionsApplyAll,
ViewOptionsUseThumb,
ViewOptionsReset,
),
),
)
val OPTIONS_EPISODES =
listOf(
PreferenceGroup(
title = R.string.general,
preferences =
listOf(
ViewOptionsCardHeight,
ViewOptionsSpacing,
ViewOptionsShowTitles,
ViewOptionsImageType,
ViewOptionsAspectRatio,
ViewOptionsContentScale,
),
),
PreferenceGroup(
title = R.string.for_episodes,
preferences =
listOf(
ViewOptionsUseSeries,
ViewOptionsEpisodeImageType,
ViewOptionsEpisodeAspectRatio,
ViewOptionsEpisodeContentScale,
),
),
PreferenceGroup(
title = R.string.more,
preferences =
listOf(
ViewOptionsUseThumb,
ViewOptionsReset,
),
),
)
val GENRE_OPTIONS =
listOf(
PreferenceGroup(
title = R.string.general,
preferences =
listOf(
ViewOptionsCardHeight,
ViewOptionsSpacing,
ViewOptionsReset,
),
),
)
}

View file

@ -0,0 +1,110 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
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.focus.focusRestorer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.ifElse
@Composable
fun HomeSettingsAddRow(
libraries: List<Library>,
showDiscover: Boolean,
onClick: (Library) -> Unit,
onClickMeta: (MetaRowType) -> Unit,
modifier: Modifier,
firstFocus: FocusRequester = remember { FocusRequester() },
) {
// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
Column(modifier = modifier) {
TitleText(stringResource(R.string.add_row))
LazyColumn(
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.fillMaxHeight()
.focusRestorer(firstFocus),
) {
itemsIndexed(
listOf(
MetaRowType.CONTINUE_WATCHING,
MetaRowType.NEXT_UP,
MetaRowType.COMBINED_CONTINUE_WATCHING,
),
) { index, type ->
HomeSettingsListItem(
selected = false,
headlineText = stringResource(type.stringId),
onClick = { onClickMeta.invoke(type) },
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
)
}
item {
TitleText(stringResource(R.string.library))
HorizontalDivider()
}
itemsIndexed(libraries) { index, library ->
HomeSettingsListItem(
selected = false,
headlineText = library.name,
onClick = { onClick.invoke(library) },
modifier = Modifier, // .ifElse(index == 0, Modifier.focusRequester(firstFocus)),
)
}
item {
TitleText(stringResource(R.string.more))
HorizontalDivider()
}
itemsIndexed(
listOf(
MetaRowType.FAVORITES,
MetaRowType.COLLECTION,
MetaRowType.PLAYLIST,
),
) { index, type ->
HomeSettingsListItem(
selected = false,
headlineText = stringResource(type.stringId),
onClick = { onClickMeta.invoke(type) },
modifier = Modifier,
)
}
if (showDiscover) {
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(MetaRowType.DISCOVER.stringId),
onClick = { onClickMeta.invoke(MetaRowType.DISCOVER) },
modifier = Modifier,
)
}
}
}
}
}
enum class MetaRowType(
@param:StringRes val stringId: Int,
) {
CONTINUE_WATCHING(R.string.continue_watching),
NEXT_UP(R.string.next_up),
COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next),
FAVORITES(R.string.favorites),
DISCOVER(R.string.discover),
COLLECTION(R.string.collection),
PLAYLIST(R.string.playlist),
}

View file

@ -0,0 +1,38 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
/**
* Tracking the pages for selecting and configuring rows
*/
@Serializable
sealed interface HomeSettingsDestination : NavKey {
@Serializable
data object RowList : HomeSettingsDestination
@Serializable
data object AddRow : HomeSettingsDestination
@Serializable
data class ChooseRowType(
val library: Library,
) : HomeSettingsDestination
@Serializable
data class RowSettings(
val rowId: Int,
) : HomeSettingsDestination
@Serializable
data object ChooseFavorite : HomeSettingsDestination
@Serializable
data object ChooseDiscover : HomeSettingsDestination
@Serializable
data object GlobalSettings : HomeSettingsDestination
@Serializable
data object Presets : HomeSettingsDestination
}

View file

@ -0,0 +1,64 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.focus.focusRestorer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
fun HomeSettingsFavoriteList(
onClick: (BaseItemKind) -> Unit,
modifier: Modifier = Modifier,
firstFocus: FocusRequester = remember { FocusRequester() },
) {
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
Column(modifier = modifier) {
TitleText(
stringResource(R.string.add_row_for, stringResource(R.string.favorites)),
)
LazyColumn(
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.fillMaxHeight()
.focusRestorer(firstFocus),
) {
itemsIndexed(favoriteOptionsList) { index, type ->
HomeSettingsListItem(
selected = false,
headlineText = stringResource(favoriteOptions[type]!!),
onClick = { onClick.invoke(type) },
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
)
}
}
}
}
val favoriteOptions by lazy {
mapOf(
BaseItemKind.MOVIE to R.string.movies,
BaseItemKind.SERIES to R.string.tv_shows,
BaseItemKind.EPISODE to R.string.episodes,
BaseItemKind.VIDEO to R.string.videos,
BaseItemKind.PLAYLIST to R.string.playlists,
BaseItemKind.PERSON to R.string.people,
)
}
val favoriteOptionsList by lazy { favoriteOptions.keys.toList() }

View file

@ -0,0 +1,184 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.HorizontalDivider
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.focus.focusRestorer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun HomeSettingsGlobal(
preferences: AppPreferences,
onPreferenceChange: (AppPreferences) -> Unit,
onClickResize: (Int) -> Unit,
onClickSave: () -> Unit,
onClickLoad: () -> Unit,
onClickLoadWeb: () -> Unit,
onClickReset: () -> Unit,
modifier: Modifier = Modifier,
) {
val firstFocus: FocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
Column(modifier = modifier) {
Text(
text = stringResource(R.string.settings),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider()
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.fillMaxHeight()
.focusRestorer(firstFocus),
) {
item {
ComposablePreference(
preference = AppPreference.HomePageItems,
value = AppPreference.HomePageItems.getter.invoke(preferences),
onValueChange = {
val newPrefs = AppPreference.HomePageItems.setter.invoke(preferences, it)
onPreferenceChange.invoke(newPrefs)
},
onNavigate = {},
modifier = Modifier.focusRequester(firstFocus),
)
}
item {
ComposablePreference(
preference = AppPreference.RewatchNextUp,
value = AppPreference.RewatchNextUp.getter.invoke(preferences),
onValueChange = {
val newPrefs = AppPreference.RewatchNextUp.setter.invoke(preferences, it)
onPreferenceChange.invoke(newPrefs)
},
onNavigate = {},
modifier = Modifier,
)
}
item {
ComposablePreference(
preference = AppPreference.MaxDaysNextUp,
value = AppPreference.MaxDaysNextUp.getter.invoke(preferences),
onValueChange = {
val newPrefs = AppPreference.MaxDaysNextUp.setter.invoke(preferences, it)
onPreferenceChange.invoke(newPrefs)
},
onNavigate = {},
modifier = Modifier,
)
}
item { HorizontalDivider() }
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.increase_all_cards_size),
leadingContent = {
Icon(
imageVector = Icons.Default.KeyboardArrowUp,
contentDescription = null,
)
},
onClick = { onClickResize.invoke(1) },
modifier = Modifier,
)
}
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.decrease_all_cards_size),
leadingContent = {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
)
},
onClick = { onClickResize.invoke(-1) },
modifier = Modifier,
)
}
item { HorizontalDivider() }
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.save_to_server),
leadingContent = {
Text(
text = stringResource(R.string.fa_cloud_arrow_up),
fontFamily = FontAwesome,
)
},
onClick = onClickSave,
modifier = Modifier,
)
}
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.load_from_server),
leadingContent = {
Text(
text = stringResource(R.string.fa_cloud_arrow_down),
fontFamily = FontAwesome,
)
},
onClick = onClickLoad,
modifier = Modifier,
)
}
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.load_from_web_client),
leadingContent = {
Text(
text = stringResource(R.string.fa_download),
fontFamily = FontAwesome,
)
},
onClick = onClickLoadWeb,
modifier = Modifier,
)
}
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.reset),
leadingContent = {
Text(
text = stringResource(R.string.fa_arrows_rotate),
fontFamily = FontAwesome,
)
},
onClick = onClickReset,
modifier = Modifier,
)
}
}
}
}

View file

@ -0,0 +1,59 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ListItem
import androidx.tv.material3.ListItemBorder
import androidx.tv.material3.ListItemColors
import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.ListItemGlow
import androidx.tv.material3.ListItemScale
import androidx.tv.material3.ListItemShape
import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle
@Composable
@NonRestartableComposable
fun HomeSettingsListItem(
selected: Boolean,
onClick: () -> Unit,
headlineText: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onLongClick: (() -> Unit)? = null,
overlineContent: (@Composable () -> Unit)? = null,
supportingContent: (@Composable () -> Unit)? = null,
leadingContent: (@Composable BoxScope.() -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
tonalElevation: Dp = 3.dp,
shape: ListItemShape = ListItemDefaults.shape(),
colors: ListItemColors = ListItemDefaults.colors(),
scale: ListItemScale = ListItemDefaults.scale(),
border: ListItemBorder = ListItemDefaults.border(),
glow: ListItemGlow = ListItemDefaults.glow(),
interactionSource: MutableInteractionSource? = null,
) = ListItem(
selected = selected,
onClick = onClick,
headlineContent = {
PreferenceTitle(headlineText)
},
modifier = modifier,
enabled = enabled,
onLongClick = onLongClick,
overlineContent = overlineContent,
supportingContent = supportingContent,
leadingContent = leadingContent,
trailingContent = trailingContent,
tonalElevation = tonalElevation,
shape = shape,
colors = colors,
scale = scale,
border = border,
glow = glow,
interactionSource = interactionSource,
)

View file

@ -0,0 +1,344 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
val settingsWidth = 360.dp
@Composable
fun HomeSettingsPage(
modifier: Modifier,
viewModel: HomeSettingsViewModel = hiltViewModel(),
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val backStack = rememberNavBackStack(HomeSettingsDestination.RowList)
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) }
val state by viewModel.state.collectAsState()
var position by rememberPosition(0, 0)
// TODO discover rows
val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false)
// Adds a row, waits until its done loading, then scrolls to the new row
fun addRow(
scrollToBottom: Boolean = true,
func: () -> Job,
) {
scope.launch(ExceptionHandler(autoToast = true)) {
while (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex)
}
func.invoke().join()
if (scrollToBottom) {
listState.animateScrollToItem(state.rows.lastIndex)
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Box(
modifier =
Modifier
.width(settingsWidth)
.fillMaxHeight()
.background(color = MaterialTheme.colorScheme.surface),
) {
NavDisplay(
backStack = backStack,
// onBack = { navigationManager.goBack() },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
entryProvider = { key ->
val dest = key as HomeSettingsDestination
NavEntry(dest, contentKey = key.toString()) {
val destModifier =
Modifier
.fillMaxSize()
.padding(8.dp)
when (dest) {
HomeSettingsDestination.RowList -> {
HomeSettingsRowList(
state = state,
onClickAdd = { backStack.add(HomeSettingsDestination.AddRow) },
onClickSettings = { backStack.add(HomeSettingsDestination.GlobalSettings) },
onClickPresets = { backStack.add(HomeSettingsDestination.Presets) },
onClickMove = viewModel::moveRow,
onClickDelete = viewModel::deleteRow,
onClick = { index, row ->
backStack.add(RowSettings(row.id))
scope.launch(ExceptionHandler()) {
Timber.v("Scroll to $index")
listState.animateScrollToItem(index)
// Update backdrop to first item in the row
(state.rowData.getOrNull(index) as? HomeRowLoadingState.Success)
?.items
?.firstOrNull()
?.let {
viewModel.updateBackdrop(it)
}
position = RowColumn(index, 0)
}
},
modifier = destModifier,
)
}
is HomeSettingsDestination.AddRow -> {
HomeSettingsAddRow(
libraries = state.libraries,
showDiscover = discoverEnabled,
onClick = { backStack.add(ChooseRowType(it)) },
onClickMeta = {
when (it) {
MetaRowType.CONTINUE_WATCHING,
MetaRowType.NEXT_UP,
MetaRowType.COMBINED_CONTINUE_WATCHING,
-> {
addRow { viewModel.addRow(it) }
}
MetaRowType.FAVORITES -> {
backStack.add(HomeSettingsDestination.ChooseFavorite)
}
MetaRowType.DISCOVER -> {
backStack.add(HomeSettingsDestination.ChooseDiscover)
}
MetaRowType.COLLECTION -> {
searchForDialog = BaseItemKind.BOX_SET
}
MetaRowType.PLAYLIST -> {
searchForDialog = BaseItemKind.PLAYLIST
}
}
},
modifier = destModifier,
)
}
is ChooseRowType -> {
HomeLibraryRowTypeList(
library = dest.library,
onClick = { type ->
when (type) {
LibraryRowType.COLLECTION -> {
searchForDialog = BaseItemKind.BOX_SET
}
LibraryRowType.PLAYLIST -> {
searchForDialog = BaseItemKind.PLAYLIST
}
else -> {
addRow { viewModel.addRow(dest.library, type) }
}
}
},
modifier = destModifier,
)
}
is RowSettings -> {
val row =
state.rows
.first { it.id == dest.rowId }
val preferenceOptions =
remember(row.config) {
when (row.config) {
is HomeRowConfig.ContinueWatching,
is HomeRowConfig.ContinueWatchingCombined,
-> Options.OPTIONS_EPISODES
is HomeRowConfig.Genres -> Options.GENRE_OPTIONS
else -> Options.OPTIONS
}
}
val defaultViewOptions =
remember(row.config) {
when (row.config) {
is HomeRowConfig.Genres -> HomeRowViewOptions.genreDefault
else -> HomeRowViewOptions()
}
}
HomeRowSettings(
title = row.title,
preferenceOptions = preferenceOptions,
viewOptions = row.config.viewOptions,
defaultViewOptions = defaultViewOptions,
onViewOptionsChange = {
viewModel.updateViewOptions(dest.rowId, it)
},
onApplyApplyAll = {
viewModel.updateViewOptionsForAll(row.config.viewOptions)
},
modifier = destModifier,
)
}
HomeSettingsDestination.ChooseDiscover -> {
TODO()
}
HomeSettingsDestination.ChooseFavorite -> {
HomeSettingsFavoriteList(
onClick = { type ->
addRow { viewModel.addFavoriteRow(type) }
},
)
}
HomeSettingsDestination.GlobalSettings -> {
val preferences by
viewModel.preferencesDataStore.data.collectAsState(
AppPreferences.getDefaultInstance(),
)
HomeSettingsGlobal(
preferences = preferences,
onPreferenceChange = { newPrefs ->
scope.launchIO {
viewModel.preferencesDataStore.updateData { newPrefs }
}
},
onClickResize = { viewModel.resizeCards(it) },
onClickSave = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_server_settings) {
viewModel.saveToRemote()
}
},
onClickLoad = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_local_settings) {
viewModel.loadFromRemote()
}
},
onClickLoadWeb = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_local_settings) {
viewModel.loadFromRemoteWeb()
}
},
onClickReset = {
showConfirmDialog =
ShowConfirm(R.string.overwrite_local_settings) {
addRow(false) { viewModel.resetToDefault() }
}
},
modifier = destModifier,
)
}
HomeSettingsDestination.Presets -> {
HomeRowPresetsContent(
onApply = viewModel::applyPreset,
modifier = destModifier,
)
}
}
}
},
)
}
HomePageContent(
loadingState = state.loading,
homeRows = state.rowData,
position = position,
onFocusPosition = { position = it },
onClickItem = { _, _ -> },
onLongClickItem = { _, _ -> },
onClickPlay = { _, _ -> },
showClock = false,
onUpdateBackdrop = viewModel::updateBackdrop,
listState = listState,
takeFocus = false,
showEmptyRows = true,
modifier =
Modifier
.fillMaxHeight()
.weight(1f),
)
}
showConfirmDialog?.let { (body, onConfirm) ->
ConfirmDialog(
title = stringResource(R.string.confirm),
body = stringResource(body),
onCancel = { showConfirmDialog = null },
onConfirm = {
onConfirm.invoke()
showConfirmDialog = null
},
)
}
searchForDialog?.let { searchType ->
SearchForDialog(
onDismissRequest = { searchForDialog = null },
searchType = searchType,
onClick = {
searchForDialog = null
addRow { viewModel.addRow(searchType, it) }
},
)
}
}
data class ShowConfirm(
@param:StringRes val body: Int,
val onConfirm: () -> Unit,
)

View file

@ -0,0 +1,269 @@
package com.github.damontecres.wholphin.ui.main.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.launch
enum class MoveDirection {
UP,
DOWN,
}
@Composable
fun HomeSettingsRowList(
state: HomePageSettingsState,
onClick: (Int, HomeRowConfigDisplay) -> Unit,
onClickAdd: () -> Unit,
onClickSettings: () -> Unit,
onClickPresets: () -> Unit,
onClickMove: (MoveDirection, Int) -> Unit,
onClickDelete: (Int) -> Unit,
modifier: Modifier,
) {
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val itemsBeforeRows = 4
val focusRequesters =
remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } }
var position by rememberInt(0)
LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Column(modifier = modifier) {
TitleText(stringResource(R.string.customize_home))
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.fillMaxHeight()
.focusRestorer(focusRequesters[0]),
) {
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.add_row),
leadingContent = {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
)
},
onClick = {
position = 0
onClickAdd.invoke()
},
modifier = Modifier.focusRequester(focusRequesters[0]),
)
}
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.settings),
leadingContent = {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = null,
)
},
onClick = {
position = 1
onClickSettings.invoke()
},
modifier = Modifier.focusRequester(focusRequesters[1]),
)
}
item {
HomeSettingsListItem(
selected = false,
headlineText = stringResource(R.string.display_presets),
supportingContent = {
Text(
text = stringResource(R.string.display_presets_description),
)
},
leadingContent = {
Text(
text = stringResource(R.string.fa_sliders),
fontFamily = FontAwesome,
)
},
onClick = {
position = 2
onClickPresets.invoke()
},
modifier = Modifier.focusRequester(focusRequesters[2]),
)
}
item {
TitleText(stringResource(R.string.home_rows) + " (${state.rows.size})")
HorizontalDivider()
}
itemsIndexed(state.rows, key = { _, row -> row.id }) { index, row ->
HomeRowConfigContent(
config = row,
moveUpAllowed = index > 0,
moveDownAllowed = index != state.rows.lastIndex,
deleteAllowed = state.rows.size > 1,
onClickMove = {
onClickMove.invoke(it, index)
scope.launch {
val scrollIndex =
itemsBeforeRows + if (it == MoveDirection.UP) index - 1 else index + 1
if (scrollIndex < listState.firstVisibleItemIndex ||
scrollIndex > listState.layoutInfo.visibleItemsInfo.lastIndex
) {
listState.animateScrollToItem(scrollIndex)
}
}
},
onClickDelete = {
if (index != state.rows.lastIndex) {
focusManager.moveFocus(FocusDirection.Down)
} else {
focusManager.moveFocus(FocusDirection.Up)
}
onClickDelete.invoke(index)
},
onClick = {
position = itemsBeforeRows + index
onClick.invoke(index, row)
},
modifier =
Modifier
.fillMaxWidth()
.animateItem()
.focusRequester(focusRequesters[itemsBeforeRows + index]),
)
}
}
}
}
@Composable
fun HomeRowConfigContent(
config: HomeRowConfigDisplay,
moveUpAllowed: Boolean,
moveDownAllowed: Boolean,
deleteAllowed: Boolean,
onClick: () -> Unit,
onClickMove: (MoveDirection) -> Unit,
onClickDelete: () -> Unit,
modifier: Modifier,
) {
Box(
modifier = modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 40.dp, max = 88.dp),
) {
HomeSettingsListItem(
selected = false,
headlineText = config.title,
onClick = onClick,
modifier = Modifier.weight(1f),
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.wrapContentWidth(),
) {
Button(
onClick = { onClickMove.invoke(MoveDirection.UP) },
enabled = moveUpAllowed,
) {
Text(
text = stringResource(R.string.fa_caret_up),
fontFamily = FontAwesome,
)
}
Button(
onClick = { onClickMove.invoke(MoveDirection.DOWN) },
enabled = moveDownAllowed,
) {
Text(
text = stringResource(R.string.fa_caret_down),
fontFamily = FontAwesome,
)
}
Button(
onClick = onClickDelete,
enabled = deleteAllowed,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "delete",
modifier = Modifier,
)
}
}
}
}
}
@Composable
@NonRestartableComposable
fun TitleText(
title: String,
modifier: Modifier = Modifier,
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Start,
modifier =
modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
)
}

View file

@ -0,0 +1,754 @@
package com.github.damontecres.wholphin.ui.main.settings
import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.Immutable
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomePageSettings
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatching
import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatchingCombined
import com.github.damontecres.wholphin.data.model.HomeRowConfig.Genres
import com.github.damontecres.wholphin.data.model.HomeRowConfig.NextUp
import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyAdded
import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyReleased
import com.github.damontecres.wholphin.data.model.HomeRowConfig.Suggestions
import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels
import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomeRowConfigDisplay
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.properties.Delegates
@HiltViewModel
class HomeSettingsViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val homeSettingsService: HomeSettingsService,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
private val navDrawerService: NavDrawerService,
private val backdropService: BackdropService,
private val seerrServerRepository: SeerrServerRepository,
val preferencesDataStore: DataStore<AppPreferences>,
@param:IoCoroutineScope private val ioScope: CoroutineScope,
) : ViewModel() {
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
val state: StateFlow<HomePageSettingsState> = _state
private var idCounter by Delegates.notNull<Int>()
val discoverEnabled = seerrServerRepository.active
private var originalLocalSettings: HomePageSettings? = null
private var originalRemoteSettings: HomePageSettings? = null
init {
addCloseable { saveToLocal() }
viewModelScope.launchIO {
val userDto = serverRepository.currentUserDto.value ?: return@launchIO
val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess)
val currentSettings =
homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY }
originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id)
originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id)
Timber.v("currentSettings=%s", currentSettings)
idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(
libraries = libraries,
rows = currentSettings.rows,
)
}
fetchRowData()
}
}
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
private suspend fun fetchRowData() {
val limit = 8
val semaphore = Semaphore(4)
val rows =
serverRepository.currentUserDto.value?.let { userDto ->
val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences
state.value
.let { state ->
state.rows
.map { it.config }
.map { row ->
viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit {
homeSettingsService.fetchDataForRow(
row = row,
scope = viewModelScope,
prefs = prefs,
userDto = userDto,
libraries = state.libraries,
limit = limit,
isRefresh = false,
)
}
}
}
}.awaitAll()
}
rows?.let { rows ->
rows
.firstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() }
?.let {
it as HomeRowLoadingState.Success
it.items.firstOrNull()?.let {
Timber.v("Updating backdrop")
updateBackdrop(it)
}
}
updateState {
it.copy(loading = LoadingState.Success, rowData = rows)
}
}
}
private fun <T> List<T>.move(
direction: MoveDirection,
index: Int,
): List<T> =
toMutableList().apply {
if (direction == MoveDirection.DOWN) {
val down = this[index]
val up = this[index + 1]
set(index, up)
set(index + 1, down)
} else {
val up = this[index]
val down = this[index - 1]
set(index - 1, up)
set(index, down)
}
}
fun moveRow(
direction: MoveDirection,
index: Int,
) {
viewModelScope.launchIO {
updateState {
val rows = it.rows.move(direction, index)
val rowData = it.rowData.move(direction, index)
it.copy(
rows = rows,
rowData = rowData,
)
}
}
// viewModelScope.launchIO { fetchRowData() }
}
fun deleteRow(index: Int) {
viewModelScope.launchIO {
updateState {
val rows = it.rows.toMutableList().apply { removeAt(index) }
val rowData = it.rowData.toMutableList().apply { removeAt(index) }
it.copy(
rows = rows,
rowData = rowData,
)
}
}
}
fun addRow(type: MetaRowType): Job =
viewModelScope.launchIO {
val id = idCounter++
val newRow =
when (type) {
MetaRowType.CONTINUE_WATCHING -> {
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.continue_watching),
config = ContinueWatching(),
)
}
MetaRowType.NEXT_UP -> {
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.next_up),
config = NextUp(),
)
}
MetaRowType.COMBINED_CONTINUE_WATCHING -> {
HomeRowConfigDisplay(
id = id,
title = context.getString(R.string.combine_continue_next),
config = ContinueWatchingCombined(),
)
}
MetaRowType.FAVORITES,
MetaRowType.COLLECTION,
MetaRowType.PLAYLIST,
-> {
throw IllegalArgumentException("Should use a different addRow() instead")
}
MetaRowType.DISCOVER -> {
TODO()
}
}
updateState {
it.copy(
loading = LoadingState.Loading,
rows = it.rows.toMutableList().apply { add(newRow) },
)
}
fetchRowData()
}
fun addRow(
library: Library,
rowType: LibraryRowType,
): Job =
viewModelScope.launchIO {
val id = idCounter++
val newRow =
when (rowType) {
LibraryRowType.RECENTLY_ADDED -> {
val title =
library.name.let { context.getString(R.string.recently_added_in, it) }
HomeRowConfigDisplay(
id = id,
title = title,
config = RecentlyAdded(library.itemId),
)
}
LibraryRowType.RECENTLY_RELEASED -> {
val title =
library.name.let {
context.getString(
R.string.recently_released_in,
it,
)
}
HomeRowConfigDisplay(
id = id,
title = title,
config = RecentlyReleased(library.itemId),
)
}
LibraryRowType.GENRES -> {
val title = library.name.let { context.getString(R.string.genres_in, it) }
HomeRowConfigDisplay(
id = id,
title = title,
config = Genres(library.itemId),
)
}
LibraryRowType.SUGGESTIONS -> {
val title =
library.name.let { context.getString(R.string.suggestions_for, it) }
HomeRowConfigDisplay(
id = id,
title = title,
config = Suggestions(library.itemId),
)
}
LibraryRowType.TV_CHANNELS -> {
val title = context.getString(R.string.channels)
HomeRowConfigDisplay(
id = id,
title = title,
config =
TvChannels(
viewOptions = HomeRowViewOptions.liveTvDefault,
),
)
}
LibraryRowType.TV_PROGRAMS -> {
val title = context.getString(R.string.watch_live)
HomeRowConfigDisplay(
id = id,
title = title,
config = TvPrograms(),
)
}
LibraryRowType.RECENTLY_RECORDED -> {
val title = context.getString(R.string.recently_recorded)
HomeRowConfigDisplay(
id = id,
title = title,
config = RecentlyAdded(library.itemId),
)
}
LibraryRowType.COLLECTION,
LibraryRowType.PLAYLIST,
-> {
throw IllegalArgumentException("Use different addRow")
}
}
updateState {
it.copy(
loading = LoadingState.Loading,
rows = it.rows.toMutableList().apply { add(newRow) },
)
}
fetchRowData()
}
fun addFavoriteRow(type: BaseItemKind): Job =
viewModelScope.launchIO {
Timber.v("Adding favorite row for $type")
val id = idCounter++
val newRow =
HomeRowConfigDisplay(
id = id,
title =
context.getString(
R.string.favorite_items,
context.getString(favoriteOptions[type]!!),
),
config = HomeRowConfig.Favorite(type),
)
updateState {
it.copy(
loading = LoadingState.Loading,
rows = it.rows.toMutableList().apply { add(newRow) },
)
}
fetchRowData()
}
fun addRow(
type: BaseItemKind,
parent: BaseItem,
) = viewModelScope.launchIO {
Timber.v("Adding %s row for %s", type, parent.id)
val id = idCounter++
val newRow =
HomeRowConfigDisplay(
id = id,
title = parent.name ?: "",
config =
HomeRowConfig.ByParent(
parentId = parent.id,
recursive = true,
),
)
updateState {
it.copy(
loading = LoadingState.Loading,
rows = it.rows.toMutableList().apply { add(newRow) },
)
}
fetchRowData()
}
fun updateViewOptions(
rowId: Int,
viewOptions: HomeRowViewOptions,
) {
viewModelScope.launchIO {
var fetchData = false
updateState {
val index = it.rows.indexOfFirst { it.id == rowId }
val config = it.rows[index].config
val newRowConfig = config.updateViewOptions(viewOptions)
val newRow = it.rows[index].copy(config = newRowConfig)
if (config.viewOptions.useSeries != viewOptions.useSeries) {
fetchData = true
}
it.copy(
rows =
it.rows.toMutableList().apply {
set(index, newRow)
},
rowData =
it.rowData.toMutableList().apply {
val row = it.rowData[index]
val newRow =
if (row is HomeRowLoadingState.Success) {
row.copy(viewOptions = viewOptions)
} else {
row
}
set(index, newRow)
},
)
}
if (fetchData) {
fetchRowData()
}
}
}
fun updateViewOptionsForAll(viewOptions: HomeRowViewOptions) {
viewModelScope.launchIO {
updateState {
it.copy(
rowData =
it.rowData.toMutableList().map { row ->
if (row is HomeRowLoadingState.Success) {
row.copy(viewOptions = viewOptions)
} else {
row
}
},
)
}
}
}
fun saveToRemote() {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
Timber.d("Saving home settings to remote")
val rows = state.value.rows.map { it.config }
val settings =
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
try {
Timber.d("saveToRemote")
homeSettingsService.saveToServer(user.id, settings)
showSaveToast()
} catch (ex: Exception) {
Timber.e(ex)
showToast(context, "Error saving: ${ex.localizedMessage}")
}
}
}
}
fun loadFromRemote() {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
Timber.d("Loading home settings from remote")
try {
_state.update { it.copy(loading = LoadingState.Loading) }
val result = homeSettingsService.loadFromServer(user.id)
if (result != null) {
Timber.v("Got remote settings")
val newRows =
result.rows.mapIndexed { index, config ->
homeSettingsService.resolve(index, config)
}
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(rows = newRows)
}
} else {
Timber.v("No remote settings")
showToast(context, "No server-side settings found")
}
fetchRowData()
} catch (ex: UnsupportedHomeSettingsVersionException) {
// TODO
Timber.w(ex)
showToast(context, "Error: ${ex.localizedMessage}")
} catch (ex: Exception) {
Timber.e(ex)
showToast(context, "Error: ${ex.localizedMessage}")
}
}
}
}
fun loadFromRemoteWeb() {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
Timber.d("Loading home settings from web")
try {
_state.update { it.copy(loading = LoadingState.Loading) }
val result = homeSettingsService.parseFromWebConfig(user.id)
if (result != null) {
Timber.v("Got web settings")
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(rows = result.rows)
}
} else {
Timber.v("No web settings")
showToast(context, "No server-side web settings found")
}
fetchRowData()
} catch (ex: Exception) {
Timber.e(ex)
showToast(context, "Error: ${ex.localizedMessage}")
}
}
}
}
fun saveToLocal() {
// This uses injected ioScope so that it will still run when the page is closing
ioScope.launchIO {
serverRepository.currentUser.value?.let { user ->
val rows = state.value.rows.map { it.config }
val settings =
HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION)
try {
Timber.d("saveToLocal")
// Only save if there are changes based on original source
val shouldSave =
if (originalLocalSettings != null) {
originalLocalSettings != settings
} else if (originalRemoteSettings != null) {
originalRemoteSettings != settings
} else {
true
}
if (shouldSave) {
homeSettingsService.saveToLocal(user.id, settings)
homeSettingsService.updateCurrent(settings)
showSaveToast()
} else {
Timber.d("No changes")
}
} catch (ex: UnsupportedHomeSettingsVersionException) {
Timber.w(ex, "Overwriting local settings")
homeSettingsService.saveToLocal(user.id, settings)
showSaveToast()
} catch (ex: Exception) {
Timber.e(ex)
showToast(context, "Error saving: ${ex.localizedMessage}")
}
}
}
}
private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) {
_state.update {
update.invoke(it)
}
homeSettingsService.currentSettings.update { HomePageResolvedSettings(state.value.rows) }
}
fun resizeCards(relative: Int) {
viewModelScope.launchIO {
updateState {
val newRows =
it.rows.toMutableList().map { row ->
val vo = row.config.viewOptions
val newVo = vo.copy(heightDp = vo.heightDp + (4 * relative))
row.copy(config = row.config.updateViewOptions(newVo))
}
it.copy(
rows = newRows,
rowData =
it.rowData.toMutableList().mapIndexed { index, row ->
if (row is HomeRowLoadingState.Success) {
row.copy(viewOptions = newRows[index].config.viewOptions)
} else {
row
}
},
)
}
}
}
fun resetToDefault() =
viewModelScope.launchIO {
val userId = serverRepository.currentUser.value?.id ?: return@launchIO
_state.update { it.copy(loading = LoadingState.Loading) }
val result = homeSettingsService.createDefault(userId)
idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0
_state.update {
it.copy(rows = result.rows, rowData = emptyList())
}
fetchRowData()
}
private suspend fun showSaveToast() =
showToast(
context,
context.getString(R.string.settings_saved),
Toast.LENGTH_SHORT,
)
fun applyPreset(preset: HomeRowPresets) {
_state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launchIO {
val state = state.value
val typeCache = mutableMapOf<UUID, CollectionType?>()
suspend fun getCollectionType(itemId: UUID): CollectionType =
typeCache.getOrPut(itemId) {
state.libraries
.firstOrNull { it.itemId == itemId }
?.collectionType
?: api.userLibraryApi
.getItem(itemId)
.content.collectionType ?: CollectionType.UNKNOWN
} ?: CollectionType.UNKNOWN
val newRows =
state.rows.map {
val newConfig =
when (it.config) {
is ContinueWatching,
is NextUp,
is ContinueWatchingCombined,
-> {
it.config.updateViewOptions(preset.continueWatching)
}
is HomeRowConfig.ByParent -> {
val collectionType = getCollectionType(it.config.parentId)
val viewOptions = preset.getByCollectionType(collectionType)
it.config.updateViewOptions(viewOptions)
}
is HomeRowConfig.Favorite -> {
val viewOptions =
when (it.config.kind) {
BaseItemKind.MOVIE -> preset.movieLibrary
BaseItemKind.SERIES -> preset.tvLibrary
BaseItemKind.EPISODE -> preset.continueWatching
BaseItemKind.VIDEO -> preset.videoLibrary
BaseItemKind.PLAYLIST -> preset.playlist
BaseItemKind.PERSON -> preset.movieLibrary
else -> preset.movieLibrary
}
it.config.updateViewOptions(viewOptions)
}
is Genres -> {
it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize))
}
is HomeRowConfig.GetItems -> {
it.config
}
is RecentlyAdded -> {
val collectionType = getCollectionType(it.config.parentId)
val viewOptions = preset.getByCollectionType(collectionType)
it.config.updateViewOptions(viewOptions)
}
is RecentlyReleased -> {
val collectionType = getCollectionType(it.config.parentId)
val viewOptions = preset.getByCollectionType(collectionType)
it.config.updateViewOptions(viewOptions)
}
is HomeRowConfig.Recordings -> {
it.config.updateViewOptions(preset.tvLibrary)
}
is Suggestions -> {
val collectionType = getCollectionType(it.config.parentId)
val viewOptions = preset.getByCollectionType(collectionType)
it.config.updateViewOptions(viewOptions)
}
is HomeRowConfig.TvPrograms -> {
it.config.updateViewOptions(preset.liveTv)
}
is HomeRowConfig.TvChannels -> {
it.config.updateViewOptions(preset.liveTv)
}
}
it.copy(config = newConfig)
}
_state.update {
it.copy(
loading = LoadingState.Success,
rows = newRows,
rowData =
it.rowData.toMutableList().mapIndexed { index, row ->
if (row is HomeRowLoadingState.Success) {
row.copy(viewOptions = newRows[index].config.viewOptions)
} else {
row
}
},
)
}
}
}
}
data class HomePageSettingsState(
val loading: LoadingState,
val rows: List<HomeRowConfigDisplay>,
val rowData: List<HomeRowLoadingState>,
val libraries: List<Library>,
) {
companion object {
val EMPTY =
HomePageSettingsState(
LoadingState.Pending,
emptyList(),
emptyList(),
emptyList(),
)
}
}
@Immutable
@Serializable
data class Library(
@Serializable(UUIDSerializer::class) val itemId: UUID,
val name: String,
val type: BaseItemKind,
val collectionType: CollectionType,
val isRecordingFolder: Boolean,
)

View file

@ -33,6 +33,9 @@ sealed class Destination(
val id: Long = 0L,
) : Destination()
@Serializable
data object HomeSettings : Destination(true)
@Serializable
data class Settings(
val screen: PreferenceScreenOption,

View file

@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview
import com.github.damontecres.wholphin.ui.discover.DiscoverPage
import com.github.damontecres.wholphin.ui.main.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
@ -66,6 +67,10 @@ fun DestinationContent(
)
}
is Destination.HomeSettings -> {
HomeSettingsPage(modifier)
}
is Destination.PlaybackList,
is Destination.Playback,
-> {
@ -119,7 +124,9 @@ fun DestinationContent(
)
}
BaseItemKind.VIDEO -> {
BaseItemKind.VIDEO,
BaseItemKind.MUSIC_VIDEO,
-> {
// TODO Use VideoDetails
MovieDetails(
preferences,
@ -220,7 +227,7 @@ fun DestinationContent(
else -> {
Timber.w("Unsupported item type: ${destination.type}")
Text("Unsupported item type: ${destination.type}")
Text("Unsupported item type: ${destination.type}", modifier)
}
}
}

View file

@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
@ -66,20 +67,19 @@ import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.setup.UserIconCardImage
@ -88,10 +88,6 @@ import com.github.damontecres.wholphin.ui.theme.LocalTheme
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.CollectionType
@ -104,42 +100,66 @@ class NavDrawerViewModel
@Inject
constructor(
private val api: ApiClient,
private val navDrawerItemRepository: NavDrawerItemRepository,
private val navDrawerService: NavDrawerService,
val navigationManager: NavigationManager,
val setupNavigationManager: SetupNavigationManager,
val backdropService: BackdropService,
private val seerrServerRepository: SeerrServerRepository,
) : ViewModel() {
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
val state = navDrawerService.state
val selectedIndex = MutableLiveData(-1)
val showMore = MutableLiveData(false)
val moreExpanded = MutableLiveData(false)
init {
seerrServerRepository.active
.onEach {
init()
}.launchIn(viewModelScope)
fun onClickDrawerItem(
index: Int,
item: NavDrawerItem,
) {
if (item !is NavDrawerItem.More) setShowMore(false)
when (item) {
NavDrawerItem.Favorites -> {
setIndex(index)
navigationManager.navigateToFromDrawer(
Destination.Favorites,
)
}
fun init() {
viewModelScope.launchIO {
val all = navDrawerItemRepository.getNavDrawerItems()
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
withContext(Dispatchers.Main) {
this@NavDrawerViewModel.moreLibraries.value = moreLibraries
this@NavDrawerViewModel.libraries.value = libraries
NavDrawerItem.More -> {
setShowMore(!moreExpanded.value!!)
}
NavDrawerItem.Discover -> {
setIndex(index)
navigationManager.navigateToFromDrawer(
Destination.Discover,
)
}
is ServerNavDrawerItem -> {
setIndex(index)
navigationManager.navigateToFromDrawer(item.destination)
}
}
}
fun setIndex(index: Int) {
selectedIndex.value = index
}
fun setShowMore(value: Boolean) {
moreExpanded.value = value
}
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
fun updateSelectedIndex() {
viewModelScope.launchDefault {
val asDestinations =
(
libraries +
state.value.items +
listOf(
NavDrawerItem.More,
NavDrawerItem.Discover,
) + moreLibraries
) + state.value.moreItems
).map {
if (it is ServerNavDrawerItem) {
it.destination
@ -178,52 +198,6 @@ class NavDrawerViewModel
}
}
}
fun onClickDrawerItem(
index: Int,
item: NavDrawerItem,
) {
if (item !is NavDrawerItem.More) setShowMore(false)
when (item) {
NavDrawerItem.Favorites -> {
setIndex(index)
navigationManager.navigateToFromDrawer(
Destination.Favorites,
)
}
NavDrawerItem.More -> {
setShowMore(!showMore.value!!)
}
NavDrawerItem.Discover -> {
setIndex(index)
navigationManager.navigateToFromDrawer(
Destination.Discover,
)
}
is ServerNavDrawerItem -> {
setIndex(index)
navigationManager.navigateToFromDrawer(item.destination)
}
NavDrawerItem.NowPlaying -> {
setIndex(index)
navigationManager.navigateToFromDrawer(Destination.NowPlaying)
}
}
}
fun setIndex(index: Int) {
selectedIndex.value = index
}
fun setShowMore(value: Boolean) {
showMore.value = value
}
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
}
sealed interface NavDrawerItem {
@ -251,13 +225,6 @@ sealed interface NavDrawerItem {
override fun name(context: Context): String = context.getString(R.string.discover)
}
object NowPlaying : NavDrawerItem {
override val id: String
get() = "a_nowplaying"
override fun name(context: Context): String = context.getString(R.string.now_playing)
}
}
data class ServerNavDrawerItem(
@ -266,9 +233,13 @@ data class ServerNavDrawerItem(
val destination: Destination,
val type: CollectionType,
) : NavDrawerItem {
override val id: String = "s_" + itemId.toServerString()
override val id: String = getId(itemId)
override fun name(context: Context): String = name
companion object {
fun getId(itemId: UUID) = "s_" + itemId.toServerString()
}
}
/**
@ -290,6 +261,7 @@ fun NavDrawer(
key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either
),
) {
LaunchedEffect(Unit) { viewModel.updateSelectedIndex() }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val density = LocalDensity.current
@ -301,15 +273,12 @@ fun NavDrawer(
drawerState.setValue(DrawerValue.Open)
focusRequester.requestFocus()
}
val moreLibraries by viewModel.moreLibraries.observeAsState(listOf())
val libraries by viewModel.libraries.observeAsState(listOf())
LaunchedEffect(Unit) { viewModel.init() }
val showMore by viewModel.showMore.observeAsState(false)
// A negative index is a built in page, >=0 is a library
val state by viewModel.state.collectAsState()
val moreExpanded by viewModel.moreExpanded.observeAsState(false)
// A negative index is a built-in page, >=0 is a library
val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) {
BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) {
viewModel.setShowMore(false)
}
@ -424,12 +393,13 @@ fun NavDrawer(
),
)
}
itemsIndexed(libraries) { index, it ->
itemsIndexed(state.items) { index, it ->
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = it,
selected = selectedIndex == index,
moreExpanded = showMore,
moreExpanded = moreExpanded,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
@ -443,16 +413,46 @@ fun NavDrawer(
),
)
}
if (showMore) {
itemsIndexed(moreLibraries) { index, it ->
val adjustedIndex = (index + libraries.size + 1)
}
if (state.moreItems.isNotEmpty()) {
item {
val index = state.items.size
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = NavDrawerItem.More,
selected = selectedIndex == index,
moreExpanded = moreExpanded,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.onClickDrawerItem(index, NavDrawerItem.More)
},
modifier =
Modifier
.ifElse(
selectedIndex == index,
Modifier.focusRequester(focusRequester),
),
)
}
}
if (moreExpanded) {
itemsIndexed(state.moreItems) { index, it ->
val adjustedIndex =
remember(state) { (index + state.items.size + 1) }
if (it !is NavDrawerItem.Discover || state.discoverEnabled) {
val interactionSource = remember { MutableInteractionSource() }
NavItem(
library = it,
selected = selectedIndex == adjustedIndex,
moreExpanded = showMore,
moreExpanded = moreExpanded,
drawerOpen = isOpen,
onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
onClick = {
viewModel.onClickDrawerItem(
adjustedIndex,
it,
)
},
containerColor =
if (isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
@ -469,6 +469,7 @@ fun NavDrawer(
)
}
}
}
item {
val interactionSource = remember { MutableInteractionSource() }
IconNavItem(
@ -637,10 +638,6 @@ fun NavigationDrawerScope.NavItem(
else -> R.string.fa_film
}
}
NavDrawerItem.NowPlaying -> {
R.string.fa_circle_play
}
}
}
val focused by interactionSource.collectIsFocusedAsState()

View file

@ -1,7 +1,12 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.core.net.toUri
import androidx.media3.common.MediaMetadata
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.extensions.ticks
data class CurrentMediaInfo(
val sourceId: String?,
@ -15,3 +20,22 @@ data class CurrentMediaInfo(
val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null)
}
}
fun BaseItem.toMediaMetadata(imageUrl: String?): MediaMetadata =
MediaMetadata
.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setReleaseYear(data.productionYear)
.setDescription(data.overview)
.setArtworkUri(imageUrl?.toUri())
.setDurationMs(data.runTimeTicks?.ticks?.inWholeMilliseconds)
.setMediaType(
when (type) {
BaseItemKind.MOVIE -> MediaMetadata.MEDIA_TYPE_MOVIE
BaseItemKind.EPISODE -> MediaMetadata.MEDIA_TYPE_TV_SHOW
BaseItemKind.VIDEO -> MediaMetadata.MEDIA_TYPE_VIDEO
BaseItemKind.TV_CHANNEL, BaseItemKind.CHANNEL, BaseItemKind.LIVE_TV_CHANNEL -> MediaMetadata.MEDIA_TYPE_TV_CHANNEL
else -> MediaMetadata.MEDIA_TYPE_VIDEO
},
).build()

View file

@ -102,7 +102,7 @@ fun DownloadSubtitlesContent(
.padding(PaddingValues(24.dp)),
) {
Text(
text = "Search & download subtitles",
text = stringResource(R.string.search_and_download_subtitles),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@ -113,7 +113,7 @@ fun DownloadSubtitlesContent(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Language",
text = stringResource(R.string.language),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
@ -137,7 +137,7 @@ fun DownloadSubtitlesContent(
}
if (dialogItems.isEmpty()) {
Text(
text = "No remote subtitles were found",
text = stringResource(R.string.no_subtitles_found),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)

View file

@ -1,20 +1,22 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.ui.layout.ContentScale
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.PrefContentScale
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
val playbackScaleOptions =
mapOf(
ContentScale.Fit to "Fit",
ContentScale.None to "None",
ContentScale.Crop to "Crop",
ContentScale.Fit to R.string.content_scale_fit,
ContentScale.None to R.string.none,
ContentScale.Crop to R.string.content_scale_crop,
// ContentScale.Inside to "Inside",
ContentScale.FillBounds to "Fill",
ContentScale.FillWidth to "Fill Width",
ContentScale.FillHeight to "Fill Height",
ContentScale.FillBounds to R.string.content_scale_fill,
ContentScale.FillWidth to R.string.content_scale_fill_width,
ContentScale.FillHeight to R.string.content_scale_fill_height,
)
val PrefContentScale.scale: ContentScale
@ -81,3 +83,20 @@ val BaseItemKind.playable: Boolean
BaseItemKind.YEAR,
-> false
}
fun getTypeFor(collectionType: CollectionType): BaseItemKind? =
when (collectionType) {
CollectionType.UNKNOWN -> null
CollectionType.MOVIES -> BaseItemKind.MOVIE
CollectionType.TVSHOWS -> BaseItemKind.SERIES
CollectionType.MUSIC -> BaseItemKind.AUDIO
CollectionType.MUSICVIDEOS -> BaseItemKind.MUSIC_VIDEO
CollectionType.TRAILERS -> BaseItemKind.TRAILER
CollectionType.HOMEVIDEOS -> BaseItemKind.VIDEO
CollectionType.BOXSETS -> BaseItemKind.BOX_SET
CollectionType.BOOKS -> BaseItemKind.BOOK
CollectionType.PHOTOS -> BaseItemKind.PHOTO_ALBUM
CollectionType.LIVETV -> BaseItemKind.LIVE_TV_CHANNEL
CollectionType.PLAYLISTS -> BaseItemKind.PLAYLIST
CollectionType.FOLDERS -> BaseItemKind.FOLDER
}

View file

@ -144,7 +144,9 @@ fun PlaybackDialog(
BottomDialogItem(
data = PlaybackDialogType.VIDEO_SCALE,
headline = stringResource(R.string.video_scale),
supporting = playbackScaleOptions[settings.contentScale],
supporting =
playbackScaleOptions[settings.contentScale]
?.let { stringResource(it) },
),
)
}
@ -220,7 +222,7 @@ fun PlaybackDialog(
playbackScaleOptions.map { (scale, name) ->
BottomDialogItem(
data = scale,
headline = name,
headline = stringResource(name),
supporting = null,
)
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
@ -18,10 +19,15 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -32,6 +38,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
@ -251,8 +258,34 @@ fun PlaybackOverlay(
exit = slideOutVertically { it / 2 } + fadeOut(),
) {
if (chapters.isNotEmpty()) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val chapterIndex =
remember {
val position = playerControls.currentPosition.milliseconds
val index =
chapters
.indexOfFirst { it.position > position }
.minus(1)
.let {
if (it < 0) {
// Didn't find a chapter, so it's either the first or last
if (position < chapters.first().position) {
0
} else {
chapters.lastIndex
}
} else {
it
}
}.coerceIn(0, chapters.lastIndex)
index
}
val listState = rememberLazyListState(chapterIndex)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
LaunchedEffect(Unit) {
bringIntoViewRequester.bringIntoView()
focusRequester.tryRequestFocus()
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
@ -273,6 +306,7 @@ fun PlaybackOverlay(
style = MaterialTheme.typography.titleLarge,
)
LazyRow(
state = listState,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
@ -302,9 +336,18 @@ fun PlaybackOverlay(
},
interactionSource = interactionSource,
modifier =
Modifier.ifElse(
Modifier
.ifElse(
index == chapterIndex,
Modifier
.focusRequester(focusRequester)
.bringIntoViewRequester(bringIntoViewRequester),
).ifElse(
index == 0,
Modifier.focusRequester(focusRequester),
Modifier.focusProperties {
// Prevent scrolling left on first card to prevent moving down
left = FocusRequester.Cancel
},
),
)
}
@ -443,6 +486,12 @@ fun PlaybackOverlay(
text = (seekProgressMs / 1000L).seconds.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
Modifier
.background(
Color.Black.copy(alpha = 0.6f),
shape = RoundedCornerShape(4.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
@ -525,13 +574,22 @@ fun Controller(
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onNextStateFocus: () -> Unit = {},
) {
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
val verticalOffset by animateDpAsState(
targetValue = if (seekBarFocused) (-32).dp else 0.dp,
label = "TitleBumpOffset",
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp),
modifier =
Modifier
.padding(start = 16.dp)
.offset(y = verticalOffset),
) {
title?.let {
Text(

View file

@ -86,7 +86,6 @@ import com.github.damontecres.wholphin.util.mpv.MpvPlayer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration
@ -163,8 +162,7 @@ fun PlaybackPageContent(
itemId = UUID.randomUUID(),
),
)
val currentSegment by viewModel.currentSegment.observeAsState(null)
var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) }
val currentSegment by viewModel.currentSegment.collectAsState()
val cues by viewModel.subtitleCues.observeAsState(listOf())
var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) }
@ -302,10 +300,10 @@ fun PlaybackPageContent(
}
val showSegment =
!segmentCancelled && currentSegment != null &&
currentSegment?.interacted == false &&
nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L
BackHandler(showSegment) {
segmentCancelled = true
viewModel.updateSegment(currentSegment?.segment?.id, true)
}
Box(
@ -400,7 +398,7 @@ fun PlaybackPageContent(
onClickPlaylist = {
viewModel.playItemInPlaylist(it)
},
currentSegment = currentSegment,
currentSegment = currentSegment?.segment,
showClock = preferences.appPreferences.interfacePreferences.showClock,
)
}
@ -462,13 +460,12 @@ fun PlaybackPageContent(
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
delay(10.seconds)
segmentCancelled = true
viewModel.updateSegment(segment.segment.id, true)
}
TextButton(
stringRes = segment.type.skipStringRes,
stringRes = segment.segment.type.skipStringRes,
onClick = {
segmentCancelled = true
player.seekTo(segment.endTicks.ticks.inWholeMilliseconds)
viewModel.updateSegment(segment.segment.id, false)
},
modifier = Modifier.focusRequester(focusRequester),
)

View file

@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.DatePlayedService
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.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreationResult
@ -49,6 +50,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain
@ -57,7 +59,6 @@ import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
@ -95,6 +96,7 @@ import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaStreamType
@ -137,6 +139,7 @@ class PlaybackViewModel
private val refreshRateService: RefreshRateService,
val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
private val imageUrlService: ImageUrlService,
@Assisted private val destination: Destination,
) : ViewModel(),
Player.Listener,
@ -164,8 +167,7 @@ class PlaybackViewModel
val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY)
val currentPlayback = MutableStateFlow<CurrentPlayback?>(null)
val currentItemPlayback = MutableLiveData<ItemPlayback>()
val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null)
private val autoSkippedSegments = mutableSetOf<UUID>()
val currentSegment = MutableStateFlow<MediaSegmentState?>(null)
val subtitleCues = MutableLiveData<List<Cue>>(listOf())
@ -672,7 +674,15 @@ class PlaybackViewModel
MediaItem
.Builder()
.setMediaId(itemId.toString())
.setUri(mediaUrl.toUri())
.setMediaMetadata(
item.toMediaMetadata(
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
useSeriesForPrimary = true,
),
),
).setUri(mediaUrl.toUri())
.setSubtitleConfigurations(listOfNotNull(externalSubtitle))
.apply {
when (source.container) {
@ -957,15 +967,19 @@ class PlaybackViewModel
}
}
// Variables for tracking segment state
private var segmentJob: Job? = null
private val autoSkippedSegments = mutableSetOf<UUID>()
private val outroShownSegments = mutableSetOf<UUID>()
/**
* Cancels listening for segments and clears current segment state
*/
private suspend fun resetSegmentState() {
private fun resetSegmentState() {
segmentJob?.cancel()
autoSkippedSegments.clear()
currentSegment.setValueOnMain(null)
outroShownSegments.clear()
currentSegment.value = null
}
/**
@ -988,8 +1002,13 @@ class PlaybackViewModel
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
}
if (currentSegment != null &&
currentSegment.itemId == this@PlaybackViewModel.itemId &&
autoSkippedSegments.add(currentSegment.id)
currentSegment.itemId == this@PlaybackViewModel.itemId
) {
if (currentSegment.id !=
this@PlaybackViewModel
.currentSegment.value
?.segment
?.id
) {
Timber.d(
"Found media segment for %s: %s, %s",
@ -997,11 +1016,13 @@ class PlaybackViewModel
currentSegment.id,
currentSegment.type,
)
}
val playlist = this@PlaybackViewModel.playlist.value
if (currentSegment.type == MediaSegmentType.OUTRO &&
prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS &&
playlist != null && playlist.hasNext()
playlist != null && playlist.hasNext() &&
outroShownSegments.add(currentSegment.id)
) {
val nextItem = playlist.peek()
Timber.v("Setting next up during outro to ${nextItem?.id}")
@ -1021,13 +1042,21 @@ class PlaybackViewModel
withContext(Dispatchers.Main) {
when (behavior) {
SkipSegmentBehavior.AUTO_SKIP -> {
this@PlaybackViewModel.currentSegment.value = null
player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1)
if (autoSkippedSegments.add(currentSegment.id)) {
onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) }
}
this@PlaybackViewModel.currentSegment.update {
MediaSegmentState(currentSegment, true)
}
}
SkipSegmentBehavior.ASK_TO_SKIP -> {
this@PlaybackViewModel.currentSegment.value =
currentSegment
this@PlaybackViewModel.currentSegment.update {
MediaSegmentState(
currentSegment,
autoSkippedSegments.contains(currentSegment.id),
)
}
}
else -> {
@ -1046,6 +1075,28 @@ class PlaybackViewModel
}
}
fun updateSegment(
segmentId: UUID?,
dismissed: Boolean,
) {
viewModelScope.launchDefault {
val segment = currentSegment.value?.segment
if (segment != null && segment.id == segmentId) {
autoSkippedSegments.add(segment.id)
if (dismissed) {
currentSegment.update {
it?.copy(interacted = true)
}
} else {
currentSegment.update {
null
}
onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) }
}
}
}
}
private fun listenForTranscodeReason(): Job =
viewModelScope.launchIO {
currentPlayback.collectLatest {
@ -1379,3 +1430,8 @@ data class PlayerState(
val player: Player,
val backend: PlayerBackend,
)
data class MediaSegmentState(
val segment: MediaSegmentDto,
val interacted: Boolean,
)

View file

@ -0,0 +1,365 @@
package com.github.damontecres.wholphin.ui.preferences
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.core.DataStore
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavDrawerItemState
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import javax.inject.Inject
data class NavDrawerPin(
val id: String,
val title: String,
val pinned: Boolean,
val item: NavDrawerItem,
) {
companion object {
fun create(
context: Context,
items: Map<NavDrawerItem, Boolean>,
) {
items.map { (item, pinned) ->
NavDrawerPin(item.id, item.name(context), pinned, item)
}
}
}
}
enum class MoveDirection {
UP,
DOWN,
}
private fun <T> List<T>.move(
direction: MoveDirection,
index: Int,
): List<T> =
toMutableList().apply {
if (direction == MoveDirection.DOWN) {
val down = this[index]
val up = this[index + 1]
set(index, up)
set(index + 1, down)
} else {
val up = this[index]
val down = this[index - 1]
set(index - 1, up)
set(index, down)
}
}
@Composable
fun NavDrawerPreference(
title: String,
summary: String?,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
viewModel: NavDrawerPreferencesViewModel = hiltViewModel(),
) {
val items by viewModel.state.collectAsState()
var showDialog by remember { mutableStateOf(false) }
ClickPreference(
title = title,
summary = summary,
onClick = { showDialog = true },
interactionSource = interactionSource,
modifier = modifier,
)
if (showDialog) {
NavDrawerPreferenceDialog(
items = items,
onDismissRequest = {
viewModel.save()
showDialog = false
},
onClick = { index ->
val newItems =
items.toMutableList().apply {
set(index, items[index].let { it.copy(pinned = !it.pinned) })
}
viewModel.update(newItems)
},
onMoveUp = { index ->
viewModel.update(items.move(MoveDirection.UP, index))
},
onMoveDown = { index ->
viewModel.update(items.move(MoveDirection.DOWN, index))
},
)
}
}
@Composable
fun NavDrawerPreferenceDialog(
items: List<NavDrawerPin>,
onDismissRequest: () -> Unit,
onClick: (Int) -> Unit,
onMoveUp: (Int) -> Unit,
onMoveDown: (Int) -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
elevation = 3.dp,
) {
Column(
modifier =
Modifier
.padding(16.dp),
) {
Text(
text = stringResource(R.string.nav_drawer_pins),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp),
)
val listState = rememberLazyListState()
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
NavDrawerPreferenceListItem(
title = item.title,
pinned = item.pinned,
moveUpAllowed = index > 0,
moveDownAllowed = index < items.lastIndex,
onClick = { onClick.invoke(index) },
onMoveUp = { onMoveUp.invoke(index) },
onMoveDown = { onMoveDown.invoke(index) },
modifier = Modifier.animateItem(),
)
}
}
}
}
}
@Composable
fun NavDrawerPreferenceListItem(
title: String,
pinned: Boolean,
moveUpAllowed: Boolean,
moveDownAllowed: Boolean,
onClick: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 40.dp, max = 88.dp),
) {
ListItem(
selected = false,
headlineContent = {
Text(
text = title,
)
},
trailingContent = {
Switch(
checked = pinned,
onCheckedChange = {
onClick.invoke()
},
)
},
onClick = onClick,
modifier = Modifier.weight(1f),
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.wrapContentWidth(),
) {
MoveButton(R.string.fa_caret_up, moveUpAllowed, onMoveUp)
MoveButton(R.string.fa_caret_down, moveDownAllowed, onMoveDown)
}
}
}
}
@Composable
private fun MoveButton(
@StringRes icon: Int,
enabled: Boolean,
onClick: () -> Unit,
) = Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(32.dp),
) {
Text(
text = stringResource(icon),
fontSize = 16.sp,
fontFamily = FontAwesome,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
@PreviewTvSpec
@Composable
fun NavDrawerPreferenceListItemPreview() {
WholphinTheme {
NavDrawerPreferenceListItem(
title = "Movies",
pinned = true,
moveUpAllowed = true,
moveDownAllowed = true,
onClick = {},
onMoveUp = {},
onMoveDown = { },
modifier = Modifier.width(360.dp),
)
}
}
@HiltViewModel
class NavDrawerPreferencesViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
val preferenceDataStore: DataStore<AppPreferences>,
val navigationManager: NavigationManager,
val backdropService: BackdropService,
private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository,
private val navDrawerService: NavDrawerService,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
) : ViewModel() {
val state = MutableStateFlow<List<NavDrawerPin>>(listOf())
init {
viewModelScope.launchDefault {
val state = navDrawerService.state.value
val user = serverRepository.currentUser.value
val seerr = seerrServerRepository.active.firstOrNull()
if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) {
return@launchDefault
}
val navDrawerPins =
withContext(Dispatchers.IO) {
serverPreferencesDao
.getNavDrawerPinnedItems(user)
.associateBy { it.itemId }
}
val allItems = state.let { it.items + it.moreItems }
val pins =
allItems
.sortedBy {
navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE
}.mapNotNull {
if (!seerr && it is NavDrawerItem.Discover) {
null
} else {
// Assume pinned if unknown
val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED
NavDrawerPin(
it.id,
it.name(context),
pinned == NavPinType.PINNED,
it,
)
}
}
this@NavDrawerPreferencesViewModel.state.value = pins
}
}
fun update(items: List<NavDrawerPin>) {
state.update { items }
}
fun save() {
viewModelScope.launchIO(ExceptionHandler(true)) {
serverRepository.currentUser.value?.let { user ->
serverRepository.currentUserDto.value?.let { userDto ->
if (user.id == userDto.id) {
val toSave =
state.value.mapIndexed { index, item ->
NavDrawerPinnedItem(
user.rowId,
item.id,
if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED,
index,
)
}
serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray())
navDrawerService.updateNavDrawer(user, userDto)
} else {
throw IllegalStateException("User IDs do not match")
}
}
}
}
}
}

View file

@ -11,8 +11,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@ -51,6 +53,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.ifElse
@ -89,10 +92,10 @@ fun PreferencesContent(
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
var showPinFlow by remember { mutableStateOf(false) }
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
val seerrConnection by viewModel.seerrConnection.collectAsState()
var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) }
var showQuickConnectDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect {
@ -165,14 +168,9 @@ fun PreferencesContent(
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
LazyColumn(
state = state,
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(0.dp),
contentPadding = PaddingValues(16.dp),
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)),
) {
stickyHeader {
Text(
text = stringResource(screenTitle),
style = MaterialTheme.typography.headlineSmall,
@ -183,7 +181,13 @@ fun PreferencesContent(
.fillMaxWidth()
.padding(vertical = 8.dp),
)
}
LazyColumn(
state = state,
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(0.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize(),
) {
if (UpdateChecker.ACTIVE &&
preferenceScreenOption == PreferenceScreenOption.BASIC &&
preferences.autoCheckForUpdates &&
@ -334,21 +338,12 @@ fun PreferencesContent(
}
AppPreference.UserPinnedNavDrawerItems -> {
val selectedItems =
navDrawerPins.keys.mapNotNull {
if (navDrawerPins[it] ?: false) it else null
}
MultiChoicePreference(
NavDrawerPreference(
title = stringResource(pref.title),
summary = pref.summary(context, null),
possibleValues = navDrawerPins.keys,
selectedValues = selectedItems.toSet(),
onValueChange = { newSelectedItems ->
viewModel.updatePins(newSelectedItems)
},
) {
Text(it.name(context))
}
modifier = Modifier,
interactionSource = interactionSource,
)
}
AppPreference.SendAppLogs -> {
@ -394,25 +389,51 @@ fun PreferencesContent(
ClickPreference(
title = stringResource(pref.title),
onClick = {
if (seerrIntegrationEnabled) {
seerrDialogMode = SeerrDialogMode.Remove
} else {
seerrVm.resetStatus()
seerrDialogMode = SeerrDialogMode.Add
seerrDialogMode =
when (val conn = seerrConnection) {
is SeerrConnectionStatus.Error -> {
SeerrDialogMode.Error(conn.serverUrl, conn.ex)
}
SeerrConnectionStatus.NotConfigured -> {
SeerrDialogMode.Add
}
is SeerrConnectionStatus.Success -> {
SeerrDialogMode.Remove(
conn.current.server.url,
)
}
}
},
modifier = Modifier,
summary =
if (seerrIntegrationEnabled) {
stringResource(R.string.enabled)
} else {
null
when (seerrConnection) {
is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server)
SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server)
is SeerrConnectionStatus.Success -> stringResource(R.string.enabled)
},
onLongClick = {},
interactionSource = interactionSource,
)
}
AppPreference.QuickConnect -> {
ClickPreference(
title = stringResource(pref.title),
onClick = {
if (currentUser != null) {
viewModel.resetQuickConnectStatus()
showQuickConnectDialog = true
}
},
modifier = Modifier,
summary = pref.summary(context, null),
onLongClick = {},
interactionSource = interactionSource,
)
}
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(
@ -456,6 +477,7 @@ fun PreferencesContent(
}
}
}
}
if (showPinFlow && currentUser != null) {
currentUser?.let { user ->
SetPinFlow(
@ -472,11 +494,11 @@ fun PreferencesContent(
)
}
}
when (seerrDialogMode) {
SeerrDialogMode.Remove -> {
when (val mode = seerrDialogMode) {
is SeerrDialogMode.Remove -> {
ConfirmDialog(
title = stringResource(R.string.remove_seerr_server),
body = currentServer?.url ?: "",
body = mode.serverUrl,
onCancel = { seerrDialogMode = SeerrDialogMode.None },
onConfirm = {
seerrVm.removeServer()
@ -503,9 +525,62 @@ fun PreferencesContent(
)
}
is SeerrDialogMode.Error -> {
val errorStr = stringResource(R.string.voice_error_server)
val body =
remember(mode) {
"""
${mode.serverUrl}
$errorStr: ${mode.ex.localizedMessage}
""".trimIndent()
}
ConfirmDialog(
title = stringResource(R.string.remove_seerr_server),
body = body,
onCancel = { seerrDialogMode = SeerrDialogMode.None },
onConfirm = {
seerrVm.removeServer()
seerrDialogMode = SeerrDialogMode.None
},
bodyColor = MaterialTheme.colorScheme.error,
)
}
SeerrDialogMode.None -> {}
}
}
if (showQuickConnectDialog) {
val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending)
val successMessage = stringResource(R.string.quick_connect_success)
LaunchedEffect(quickConnectStatus) {
when (val status = quickConnectStatus) {
LoadingState.Success -> {
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
showQuickConnectDialog = false
}
is LoadingState.Error -> {
val errorMessage = status.message ?: "Authorization failed"
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
}
else -> {}
}
}
QuickConnectDialog(
onSubmit = { code ->
viewModel.authorizeQuickConnect(code)
},
onDismissRequest = {
viewModel.resetQuickConnectStatus()
showQuickConnectDialog = false
},
)
}
}
@Composable
@ -542,10 +617,17 @@ data class CacheUsage(
val imageDiskUsed: Long,
)
private sealed class SeerrDialogMode {
data object None : SeerrDialogMode()
private sealed interface SeerrDialogMode {
data object None : SeerrDialogMode
data object Add : SeerrDialogMode()
data object Add : SeerrDialogMode
data object Remove : SeerrDialogMode()
data class Remove(
val serverUrl: String,
) : SeerrDialogMode
data class Error(
val serverUrl: String,
val ex: Exception,
) : SeerrDialogMode
}

View file

@ -2,17 +2,10 @@ package com.github.damontecres.wholphin.ui.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.isPinned
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
@ -21,13 +14,13 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
@ -44,62 +37,22 @@ class PreferencesViewModel
val backdropService: BackdropService,
private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository,
private val navDrawerItemRepository: NavDrawerItemRepository,
private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
private val deviceInfo: DeviceInfo,
private val clientInfo: ClientInfo,
) : ViewModel(),
RememberTabManager by rememberTabManager {
private lateinit var allNavDrawerItems: List<NavDrawerItem>
val navDrawerPins = MutableLiveData<Map<NavDrawerItem, Boolean>>(mapOf())
val currentUser get() = serverRepository.currentUser
val seerrEnabled =
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
}
val seerrConnection = seerrServerRepository.connection
private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus
init {
viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user ->
allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems()
val pins = serverPreferencesDao.getNavDrawerPinnedItems(user)
val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) }
this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins)
}
}
}
fun updatePins(newSelectedItems: List<NavDrawerItem>) {
viewModelScope.launchIO(ExceptionHandler(true)) {
serverRepository.currentUser.value?.let { user ->
val disabledItems =
mutableListOf<NavDrawerItem>().apply {
addAll(allNavDrawerItems)
removeAll(newSelectedItems)
}
val enabledItems = newSelectedItems.toSet()
val toSave =
disabledItems.map {
NavDrawerPinnedItem(
user.rowId,
it.id,
NavPinType.UNPINNED,
)
} +
enabledItems.map {
NavDrawerPinnedItem(
user.rowId,
it.id,
NavPinType.PINNED,
)
}
serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray())
val pins = serverPreferencesDao.getNavDrawerPinnedItems(user)
val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) }
this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins)
// fetchNavDrawerPins(user)
}
}
}
@ -123,6 +76,27 @@ class PreferencesViewModel
}
}
fun resetQuickConnectStatus() {
_quickConnectStatus.value = LoadingState.Pending
}
fun authorizeQuickConnect(code: String) {
viewModelScope.launchIO {
_quickConnectStatus.value = LoadingState.Loading
try {
val success = serverRepository.authorizeQuickConnect(code)
_quickConnectStatus.value =
if (success) {
LoadingState.Success
} else {
LoadingState.Error("Authorization failed")
}
} catch (e: Exception) {
_quickConnectStatus.value = LoadingState.Error(e)
}
}
}
companion object {
suspend fun resetSubtitleSettings(appPreferences: DataStore<AppPreferences>) {
appPreferences.updateData {

View file

@ -0,0 +1,127 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
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.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.TextButton
@Composable
fun QuickConnectDialog(
onSubmit: (String) -> Unit,
onDismissRequest: () -> Unit,
elevation: Dp = 3.dp,
) {
var code by remember { mutableStateOf("") }
var showError by remember { mutableStateOf(false) }
val isValidCode: (String) -> Boolean = { value ->
val trimmed = value.trim()
trimmed.length == 6 && trimmed.all { it.isDigit() }
}
val onSubmitCode = {
if (isValidCode(code)) {
showError = false
onSubmit(code.trim())
} else {
showError = true
}
}
Dialog(
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
onDismissRequest = onDismissRequest,
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.padding(16.dp)
.width(360.dp)
.background(
color = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation),
shape = RoundedCornerShape(8.dp),
),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.padding(16.dp),
) {
Text(
text = stringResource(R.string.quick_connect_code),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
EditTextBox(
value = code,
onValueChange = {
code = it
showError = false
},
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions =
KeyboardActions(
onDone = {
onSubmitCode()
},
),
isInputValid = { value ->
!showError || isValidCode(value)
},
modifier = Modifier.fillMaxWidth(),
)
if (showError) {
Text(
text = stringResource(R.string.quick_connect_code_error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 8.dp),
)
}
TextButton(
stringRes = R.string.submit,
onClick = onSubmitCode,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
}
}

View file

@ -62,7 +62,7 @@ fun AddSeerrServerApiKey(
val passwordFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Text(
text = "Enter URL & API Key",
text = stringResource(R.string.enter_url_api_key),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
@ -73,7 +73,7 @@ fun AddSeerrServerApiKey(
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = "URL",
text = stringResource(R.string.url),
modifier = Modifier.padding(end = 8.dp),
)
EditTextBox(
@ -104,7 +104,7 @@ fun AddSeerrServerApiKey(
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
text = "API Key",
text = stringResource(R.string.api_key),
modifier = Modifier.padding(end = 8.dp),
)
EditTextBox(

View file

@ -6,6 +6,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.DialogItem
@ -71,27 +73,26 @@ fun ChooseSeerrLoginType(
onChoose: (SeerrAuthMethod) -> Unit,
) {
val params =
remember {
DialogParams(
fromLongClick = false,
title = "Login to Seerr server",
title = stringResource(R.string.seerr_login),
items =
listOf(
DialogItem(
text = "API Key",
text = stringResource(R.string.api_key),
onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) },
),
DialogItem(
text = "Jellyfin user",
text = stringResource(R.string.seerr_jellyfin_user),
onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) },
),
DialogItem(
text = "Local user",
text = stringResource(R.string.seerr_local_user),
onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) },
),
),
)
}
DialogPopup(
params = params,
onDismissRequest = onDismissRequest,

View file

@ -314,11 +314,11 @@ fun SlideshowPage(
) {
when (loadingState) {
ImageLoadingState.Error -> {
ErrorMessage("Error loading image", null)
ErrorMessage("Error loading image", null, modifier)
}
ImageLoadingState.Loading -> {
LoadingPage()
LoadingPage(modifier)
}
is ImageLoadingState.Success -> {

View file

@ -0,0 +1,44 @@
package com.github.damontecres.wholphin.ui.util
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.lazy.LazyColumn
/**
* Overrides scrolling so that the item being scrolled to is at the top of the view offset by the provided pixels
*
* Note: the offset is necessary for anything that is focuseable, but has content before (eg a title) that needs to be displayed too
*
* Note: this applies to ALL scrollable composables within its scope, so a [LazyColumn] of [androidx.compose.foundation.lazy.LazyRow]s likely needs nested [LocalBringIntoViewSpec] overrides
*
* Example:
* ```kotlin
* val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
* CompositionLocalProvider(LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx)) {
* LazyColumn{
* items(list){
* CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
* // Content
* }
* }
* }
* }
* ```
*/
class ScrollToTopBringIntoViewSpec(
val spaceAbovePx: Float = 100f,
) : BringIntoViewSpec {
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float,
): Float {
// Timber.v(
// "calculateScrollDistance: offset=%s, size=%s, containerSize=%s",
// offset,
// size,
// containerSize,
// )
return offset - spaceAbovePx
}
}

View file

@ -9,6 +9,7 @@ val supportItemKinds =
BaseItemKind.EPISODE,
BaseItemKind.SERIES,
BaseItemKind.VIDEO,
BaseItemKind.MUSIC_VIDEO,
BaseItemKind.SEASON,
BaseItemKind.COLLECTION_FOLDER,
BaseItemKind.FOLDER,
@ -35,11 +36,20 @@ val supportedCollectionTypes =
null, // Mixed
)
val supportedHomeCollectionTypes =
setOf(
CollectionType.MOVIES,
CollectionType.TVSHOWS,
CollectionType.HOMEVIDEOS,
null, // Mixed
)
val supportedPlayableTypes =
setOf(
BaseItemKind.MOVIE,
BaseItemKind.EPISODE,
BaseItemKind.VIDEO,
BaseItemKind.MUSIC_VIDEO,
BaseItemKind.TV_CHANNEL,
BaseItemKind.TV_PROGRAM,
BaseItemKind.RECORDING,

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
/**
* Generic state for loading something from the API
@ -48,6 +49,9 @@ sealed interface RowLoadingState {
sealed interface HomeRowLoadingState {
val title: String
val completed: Boolean
get() = this is Success || this is Error
data class Pending(
override val title: String,
) : HomeRowLoadingState
@ -59,6 +63,7 @@ sealed interface HomeRowLoadingState {
data class Success(
override val title: String,
val items: List<BaseItem?>,
val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowLoadingState
data class Error(

View file

@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.util.profile
// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.4/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt
import android.media.MediaCodecInfo
import androidx.media3.common.MimeTypes
import com.github.damontecres.wholphin.util.profile.KnownDefects.supportsHi10P52
import org.jellyfin.sdk.model.api.CodecType
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
@ -92,9 +94,14 @@ fun createDeviceProfile(
val hevcMainLevel = mediaTest.getHevcMainLevel()
val hevcMain10Level = mediaTest.getHevcMain10Level()
val supportsAVC = mediaTest.supportsAVC()
val supportsAVCHigh10 = mediaTest.supportsAVCHigh10()
val supportsAVCHigh10 = mediaTest.supportsAVCHigh10() || supportsHi10P52
val avcMainLevel = mediaTest.getAVCMainLevel()
val avcHigh10Level = mediaTest.getAVCHigh10Level()
val avcHigh10Level =
if (supportsHi10P52) {
MediaCodecInfo.CodecProfileLevel.AVCLevel52
} else {
mediaTest.getAVCHigh10Level()
}
val supportsAV1 = mediaTest.supportsAV1()
val supportsAV1Main10 = mediaTest.supportsAV1Main10()
val supportsVC1 = mediaTest.supportsVc1()

View file

@ -5,7 +5,7 @@ package com.github.damontecres.wholphin.util.profile
import android.os.Build
/**
* List of devie models with known HEVC DoVi/HDR10+ playback issues.
* List of device models with known HEVC DoVi/HDR10+ playback issues.
*/
private val modelsWithDoViHdr10PlusBug =
listOf(
@ -14,6 +14,21 @@ private val modelsWithDoViHdr10PlusBug =
"AFTKM", // Amazon Fire TV 4K (2nd Gen)
)
/**
* List of device models that support H264 Hi10P 5.2, but don't advertise it
*
* Amazon devices from https://developer.amazon.com/docs/device-specs/device-specifications-fire-tv-streaming-media-player.html
*/
private val modelsWithHi10P52Support =
listOf(
"AFTMA08C15", // Fire TV Stick 4K Plus (2025)
"AFTKRT", // Amazon Fire TV 4K Max (2nd Gen)
"AFTKA", // Amazon Fire TV 4K Max (1st Gen)
"AFTKM", // Amazon Fire TV 4K (2nd Gen)
"AFTMM", // Fire TV Stick 4K - 1st Gen (2018)
)
object KnownDefects {
val hevcDoviHdr10PlusBug = Build.MODEL in modelsWithDoViHdr10PlusBug
val supportsHi10P52 = Build.MODEL in modelsWithHi10P52Support
}

View file

@ -475,4 +475,40 @@
<string name="slideshow_duration">Trvání prezentace</string>
<string name="play_videos_during_slideshow">Přehrávat videa během prezentace</string>
<string name="send_media_info_log_to_server">Odesílat protokol informací o médiích serveru</string>
<plurals name="days">
<item quantity="one">%s den</item>
<item quantity="few">%s dny</item>
<item quantity="many">%s dní</item>
<item quantity="other">%s dní</item>
</plurals>
<string name="no_limit">Bez omezení</string>
<string name="max_days_next_up">Max. počet dnů v Dalších v pořadí</string>
<string name="quick_connect">Rychlé připojení</string>
<string name="quick_connect_summary">Autorizovat jiné zařízení k přihlášení k vašemu účtu</string>
<string name="quick_connect_code">Zadejte kód Rychlého připojení</string>
<string name="quick_connect_code_error">Kód musí mít 6 číslic</string>
<string name="quick_connect_success">Zařízení úspěšně autorizováno</string>
<string name="add_row">Přidat řádek</string>
<string name="genres_in">Žánry v %1$s</string>
<string name="recently_released_in">Nedávno vydáno v %1$s</string>
<string name="height">Výška</string>
<string name="apply_all_rows">Použít na všechny řádky</string>
<string name="customize_home">Přizpůsobit domovskou stránku</string>
<string name="home_rows">Řádky na domovské stránce</string>
<string name="load_from_server">Načíst ze serveru</string>
<string name="save_to_server">Uložit na server</string>
<string name="load_from_web_client">Načíst z webového klienta</string>
<string name="use_series">Použít obrázek seriálu</string>
<string name="add_row_for">Přidat řádek pro %1$s</string>
<string name="overwrite_server_settings">Přepsat nastavení na serveru?</string>
<string name="overwrite_local_settings">Přepsat místní nastavení?</string>
<string name="for_episodes">Pro epizody</string>
<string name="suggestions_for">Návrhy na %1$s</string>
<string name="increase_all_cards_size">Zvětšit velikost všech karet</string>
<string name="decrease_all_cards_size">Zmenšit velikost všech karet</string>
<string name="use_thumb_images">Použít obrázky miniatur</string>
<string name="settings_saved">Nastavení uložena</string>
<string name="display_presets">Zobrazit předvolby</string>
<string name="display_presets_description">Vestavěné předvolby pro rychlou úpravu všech řádků</string>
<string name="customize_home_summary">Vyberte řádky a obrázky na domovské stránce</string>
</resources>

View file

@ -0,0 +1,307 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="about">Om</string>
<string name="active_recordings">Aktive optagelser</string>
<string name="add_favorite">Favorit</string>
<string name="add_server">Tilføj server</string>
<string name="add_user">Tilføj bruger</string>
<string name="audio">Lyd</string>
<string name="birthplace">Fødested</string>
<string name="bitrate">Bitrate</string>
<string name="born">Født</string>
<string name="cancel_recording">Annullér optagelse</string>
<string name="cancel_series_recording">Annullér serieoptagelse</string>
<string name="cancel">Annullér</string>
<string name="chapters">Kapitler</string>
<string name="choose_stream">Vælg %1$s</string>
<string name="clear_image_cache">Ryd billedcache</string>
<string name="collection">Samling</string>
<string name="collections">Samlinger</string>
<string name="community_rating">Brugerbedømmelse</string>
<string name="compose_error_message_title">Der opstod en fejl! Tryk på knappen for at sende logfiler til din server.</string>
<string name="confirm">Bekræft</string>
<string name="continue_watching">Fortsæt med at se</string>
<string name="critic_rating">Kritikerbedømmelse</string>
<string name="decimal_seconds">%.2f sekunder</string>
<string name="default_track">Standard</string>
<string name="delete">Slet</string>
<string name="died">Død</string>
<string name="directed_by">Instrueret af %1$s</string>
<string name="director">Instruktør</string>
<string name="disabled">Deaktiveret</string>
<string name="discovered_servers">Opdagede servere</string>
<string name="download_and_update"><![CDATA[Download og opdater]]></string>
<string name="downloading">Downloader…</string>
<string name="enabled">Aktiveret</string>
<string name="ends_at">Slutter %1$s</string>
<string name="enter_server_url">Indtast server-IP eller URL</string>
<string name="enter_server_address">Indtast serveradresse</string>
<string name="episodes">Episoder</string>
<string name="error_loading_collection">Fejl ved indlæsning af samling %1$s</string>
<string name="external_track">Ekstern</string>
<string name="favorites">Favoritter</string>
<string name="file_size">Størrelse</string>
<string name="forced_track">Tvunget</string>
<string name="genres">Genrer</string>
<string name="go_to_series">Gå til serien</string>
<string name="go_to">Gå til</string>
<string name="hide_controller_timeout">Skjul afspilningsknapper</string>
<string name="hide_debug_info">Skjul fejlfindingsoplysninger</string>
<string name="hide">Skjul</string>
<string name="home">Hjem</string>
<string name="immediate">Umiddelbar</string>
<string name="intro">Intro</string>
<string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ</string>
<string name="library">Bibliotek</string>
<string name="license_info">Licensoplysninger</string>
<string name="live_tv">Live-tv</string>
<string name="loading">Indlæser…</string>
<string name="mark_entire_series_as_played">Markér hele serien som afspillet?</string>
<string name="mark_entire_series_as_unplayed">Markér hele serien som ikke afspillet?</string>
<string name="mark_unwatched">Markér som uset</string>
<string name="mark_watched">Markér som set</string>
<string name="max_bitrate">Maks. bitrate</string>
<string name="more_like_this">Mere som dette</string>
<string name="more">Mere</string>
<plurals name="movies">
<item quantity="one">Film</item>
<item quantity="other"/>
</plurals>
<string name="name">Navn</string>
<string name="next_up">Næste</string>
<string name="no_data">Ingen data</string>
<string name="no_results">Ingen resultater</string>
<string name="no_servers_found">Ingen servere fundet</string>
<string name="no_scheduled_recordings">Ingen planlagte optagelser</string>
<string name="no_update_available">Ingen opdatering tilgængelig</string>
<string name="none">Ingen</string>
<string name="only_forced_subtitles">Kun tvungne undertekster</string>
<string name="outro">Outro</string>
<string name="path">Filsti</string>
<plurals name="people">
<item quantity="one">Personer</item>
<item quantity="other"/>
</plurals>
<string name="play_count">Antal afspilninger</string>
<string name="play_from_here">Afspil herfra</string>
<string name="play">Afspil</string>
<string name="playback_debug_info">Vis debugoplysninger for afspilning</string>
<string name="playback_speed">Afspilningshastighed</string>
<string name="playback">Afspilning</string>
<string name="playlist">Playlist</string>
<string name="playlists">Playlists</string>
<string name="preview">Forhåndsvisning</string>
<string name="profile_specific_settings">Profilindstillinger</string>
<string name="queue"></string>
<string name="recap">Opsummering</string>
<string name="recently_added_in">Nyligt tilføjet i %1$s</string>
<string name="recently_added">Nyligt tilføjet</string>
<string name="recently_recorded">Nyligt optaget</string>
<string name="recently_released">Nyligt udkommet</string>
<string name="recommended">Anbefalet</string>
<string name="record_program">Optag program</string>
<string name="record_series">Optag serie</string>
<string name="remove_favorite">Fjern fra favoritter</string>
<string name="restart">Genstart</string>
<string name="resume">Fortsæt</string>
<string name="save">Gem</string>
<string name="search_and_download"><![CDATA[Søg og download]]></string>
<string name="search">Søg</string>
<string name="searching">Søger…</string>
<string name="voice_search">Stemmesøgning</string>
<string name="voice_starting">Starter</string>
<string name="voice_search_prompt">Tal for at søge</string>
<string name="processing">Arbejder</string>
<string name="press_back_to_cancel">Tryk tilbage for at afbryde</string>
<string name="voice_error_audio">Fejl ved lydoptagelse</string>
<string name="voice_error_client">Fejl ved stemmegenkendelse</string>
<string name="voice_error_permissions">Mikrofontilladelse kræves</string>
<string name="voice_error_network">Netværksfejl</string>
<string name="voice_error_network_timeout">Netværkstimeout</string>
<string name="voice_error_no_match">Ingen tale genkendt</string>
<string name="voice_error_busy">Stemmegenkendelse optaget</string>
<string name="voice_error_server">Serverfejl</string>
<string name="voice_error_speech_timeout">Ingen tale registreret</string>
<string name="voice_error_unknown">Ukendt fejl</string>
<string name="voice_error_start_failed">Kunne ikke starte stemmegenkendelse</string>
<string name="voice_error_timeout">Tidsgrænsen for stemmegenkendelse er overskredet</string>
<string name="retry">Prøv igen</string>
<string name="select_server">Vælg server</string>
<string name="select_user">Vælg bruger</string>
<string name="show_debug_info">Vis fejlfindingsoplysninger</string>
<string name="show_next_up_when">Vis næste</string>
<string name="show">Vis</string>
<string name="shuffle">Bland</string>
<string name="skip">Spring over</string>
<string name="sort_by_date_added">Dato tilføjet</string>
<string name="sort_by_date_episode_added">Dato episode tilføjet</string>
<string name="sort_by_date_played">Dato afspillet</string>
<string name="sort_by_date_released">Udgivelsesdato</string>
<string name="sort_by_name">Navn</string>
<string name="sort_by_random">Tilfældig</string>
<string name="studios">Studier</string>
<string name="submit">Indsend</string>
<string name="subtitle_download_too_long">Downloaden tager lang tid, du skal muligvis genstarte afspilningen</string>
<string name="subtitle">Undertekst</string>
<string name="subtitles">Undertekster</string>
<string name="suggestions">Forslag</string>
<string name="switch_servers">Skift servere</string>
<string name="switch_user">Skift</string>
<string name="top_unwatched">Bedst bedømte usete</string>
<string name="trailer">Trailer</string>
<plurals name="trailers">
<item quantity="one">Trailere</item>
<item quantity="other"></item>
</plurals>
<string name="tv_dvr_schedule">DVR-plan</string>
<string name="tv_guide">TV-guide</string>
<string name="tv_season">Sæson</string>
<string name="tv_seasons">Sæsoner</string>
<plurals name="tv_shows">
<item quantity="one">TV-serier</item>
<item quantity="other"></item>
</plurals>
<string name="ui_interface">Grænseflade</string>
<string name="unknown">Ukendt</string>
<string name="updates">Opdateringer</string>
<string name="version">Version</string>
<string name="video_scale">Videoskalering</string>
<string name="video">Video</string>
<string name="videos">Videoer</string>
<string name="watch_live">Se direkte</string>
<string name="years_old">%1$d år gammel</string>
<string name="play_with_transcoding">Afspil med transkodning</string>
<string name="media_information">Medieinformation</string>
<string name="show_clock">Vis ur</string>
<string name="aired_episode_order">Udsendelsesrækkefølge</string>
<string name="create_playlist">Opret ny playliste</string>
<string name="add_to_playlist">Føj til playliste</string>
<string name="one_click_pause">Pause med ét klik</string>
<string name="one_click_pause_summary_on">Tryk på midten af D-Pad\'en for at pause/afspille</string>
<string name="italic_font">Kursiv skrift</string>
<string name="font">Skrifttype</string>
<string name="background">Baggrund</string>
<string name="success">Succes</string>
<string name="official_rating">Aldersgrænse</string>
<string name="runtime_sort">Spilletid</string>
<string name="extras">Ekstra</string>
<plurals name="other_extras">
<item quantity="one">Andet</item>
<item quantity="other"></item>
</plurals>
<plurals name="behind_the_scenes">
<item quantity="one">Bag kulisserne</item>
<item quantity="other"></item>
</plurals>
<plurals name="theme_songs">
<item quantity="one">Temasange</item>
<item quantity="other"></item>
</plurals>
<plurals name="theme_videos">
<item quantity="one">Temavideoer</item>
<item quantity="other"></item>
</plurals>
<plurals name="clips">
<item quantity="one">Klip</item>
<item quantity="other"></item>
</plurals>
<plurals name="deleted_scenes">
<item quantity="one">Slettede scener</item>
<item quantity="other"></item>
</plurals>
<plurals name="interviews">
<item quantity="one">Interview</item>
<item quantity="other"></item>
</plurals>
<plurals name="scenes">
<item quantity="one">Scener</item>
<item quantity="other"></item>
</plurals>
<plurals name="samples">
<item quantity="one">Prøver</item>
<item quantity="other"></item>
</plurals>
<plurals name="featurettes">
<item quantity="one">Specialindslag</item>
<item quantity="other"></item>
</plurals>
<plurals name="shorts">
<item quantity="one">Kortfilm</item>
<item quantity="other"></item>
</plurals>
<plurals name="downloads">
<item quantity="one">%s download</item>
<item quantity="other">%s downloads</item>
</plurals>
<plurals name="hours">
<item quantity="one">%d time</item>
<item quantity="other">%d timer</item>
</plurals>
<plurals name="days">
<item quantity="one">%s dag</item>
<item quantity="other">%s dage</item>
</plurals>
<plurals name="items">
<item quantity="one">%d element</item>
<item quantity="other">%d elementer</item>
</plurals>
<plurals name="seconds">
<item quantity="one">%d sekund</item>
<item quantity="other">%d sekunder</item>
</plurals>
<string name="ac3_supported">Enheden understøtter AC3/Dolby Digital</string>
<string name="advanced_settings">Avancerede indstillinger</string>
<string name="advanced_ui">Avanceret brugergrænseflade</string>
<string name="app_theme">Tema</string>
<string name="auto_check_for_updates">Søg automatisk efter opdateringer</string>
<string name="auto_play_next_delay">Forsinkelse før næste afspilning</string>
<string name="auto_play_next">Afspil næste automatisk</string>
<string name="check_for_updates">Søg efter opdateringer</string>
<string name="combine_continue_next_summary">Gælder kun for tv-serier</string>
<string name="combine_continue_next"><![CDATA[Kombiner Fortsæt med at se & Næste]]></string>
<string name="direct_play_ass">Direkte afspilning af ASS-undertekster</string>
<string name="direct_play_pgs">Direkte afspilning af PGS-undertekster</string>
<string name="downmix_stereo">Mix altid ned til stereo</string>
<string name="ffmpeg_extension_pref">Brug FFmpeg-dekodermodulet</string>
<string name="global_content_scale">Standard skalering</string>
<string name="install_update">Installer opdatering</string>
<string name="installed_version">Installeret version</string>
<string name="max_homepage_items">Maks antal elementer per række på startsiden</string>
<string name="nav_drawer_pins_summary">Vælg de standardelementer, der skal vises, andre vil blive skjult</string>
<string name="nav_drawer_pins">Tilpas elementer i navigationsmenuen</string>
<string name="nav_drawer_switch_on_focus_summary_off">Klik for at skifte side</string>
<string name="nav_drawer_switch_on_focus">Skift sider i navigationsmenuen ved fokus</string>
<string name="pass_out_protection">Inaktivitetsbeskyttelse</string>
<string name="play_theme_music">Afspil temamusik</string>
<string name="playback_overrides">Tilsidesættelser af afspilning</string>
<string name="remember_selected_tab">Husk valgte faner</string>
<string name="rewatch_next_up">Tillad gensening i \"Næste\"</string>
<string name="seek_bar_steps">Trinlængde for søgelinje</string>
<string name="send_app_logs_summary">Nyttig til fejlfinding</string>
<string name="send_app_logs">Send app-logfiler til den aktuelle server</string>
<string name="send_crash_reports_summary">Vil forsøge at sende til den sidst forbundne server</string>
<string name="send_crash_reports">Send nedbrudsrapporter</string>
<string name="settings">Indstillinger</string>
<string name="skip_back_on_resume_preference">Spring tilbage, når afspilningen genoptages</string>
<string name="skip_back_preference">Spring tilbage</string>
<string name="skip_commercials_behavior">Adfærd ved at springe reklamer over</string>
<string name="skip_forward_preference">Spring fremad</string>
<string name="skip_intro_behavior">Adfærd ved at springe intro over</string>
<string name="skip_outro_behavior">Adfærd ved at springe outro over</string>
<string name="skip_previews_behavior">Adfærd ved at springe forhåndsvisninger over</string>
<string name="skip_recap_behavior">Adfærd ved at springe opsummeringer over</string>
<string name="update_available">Opdatering tilgængelig</string>
<string name="update_url_summary">URL brugt til at søge efter appopdateringer</string>
<string name="update_url">URL til opdatering</string>
<string name="font_size">Tekststørrelse</string>
<string name="font_color">Tekstfarve</string>
<string name="font_opacity">Tekstgennemsigtighed</string>
<string name="bold_font">Fed skrift</string>
<string name="edge_style">Kantstil</string>
<string name="edge_color">Kantfarve</string>
<string name="background_opacity">Baggrundsopacitet</string>
<string name="background_style">Baggrundsstil</string>
<string name="subtitle_style">Undertekststil</string>
<string name="background_color">Baggrundsfarve</string>
<string name="reset">Nulstil</string>
</resources>

View file

@ -19,7 +19,7 @@
<string name="confirm">Bestätigen</string>
<string name="continue_watching">Weiterschauen</string>
<string name="critic_rating">Kritiken</string>
<string name="decimal_seconds">%.1f Sekunden</string>
<string name="decimal_seconds">%.2f Sekunden</string>
<string name="default_track">Standard</string>
<string name="delete">Löschen</string>
<string name="died">Gestorben</string>
@ -321,7 +321,7 @@
<string name="no">Nein</string>
<string name="show_titles">Titel anzeigen</string>
<string name="live_tv_repeat">Wiederholen</string>
<string name="general">Generell</string>
<string name="general">Allgemein</string>
<string name="no_trailers">Keine Trailer</string>
<string name="play_trailer">Trailer abspielen</string>
<string name="local">Lokal</string>
@ -417,4 +417,19 @@
<string name="stop_slideshow">Diashow anhalten</string>
<string name="brightness">Helligkeit</string>
<string name="contrast">Kontrast</string>
<string name="zoom_out">Verkleinern</string>
<string name="zoom_in">Vergrößern</string>
<string name="quick_connect_success">Gerät erfolgreich authorisiert</string>
<string name="saturation">Sättigung</string>
<string name="hue">Farbton</string>
<plurals name="days">
<item quantity="one">%s Tag</item>
<item quantity="other">%s Tage</item>
</plurals>
<string name="quick_connect_code_error">Der Code muss aus 6 Ziffern bestehen</string>
<string name="hdr_subtitle_style">HDR-Untertitelstil</string>
<string name="slideshow_duration">Dauer der Diashow</string>
<string name="play_videos_during_slideshow">Videos während der Diashow abspielen</string>
<string name="max_days_next_up">Maximale Tage in \"Als nächstes\"</string>
<string name="quick_connect">Quick Connect</string>
</resources>

View file

@ -15,7 +15,7 @@
<string name="commercial">Comercial</string>
<string name="confirm">Confirmar</string>
<string name="continue_watching">Continuar viendo</string>
<string name="decimal_seconds">%.1f segundos</string>
<string name="decimal_seconds">%.2f segundos</string>
<string name="delete">Eliminar</string>
<string name="died">Fallecido</string>
<string name="enabled">Activado</string>
@ -181,7 +181,7 @@
<plurals name="clips">
<item quantity="one">Clip</item>
<item quantity="many">Clips</item>
<item quantity="other"/>
<item quantity="other">Clips</item>
</plurals>
<plurals name="deleted_scenes">
<item quantity="one">Escena eliminada</item>
@ -422,4 +422,61 @@
<string name="request_4k">Solicitar en 4K</string>
<string name="software_decoding_av1">Decodificación por software AV1</string>
<string name="upgrade_mpv_toast">MPV es ahora el reproductor por defecto para HDR.\nPuedes cambiar esto en los ajustes.</string>
<plurals name="days">
<item quantity="one">%s día</item>
<item quantity="many">%s días</item>
<item quantity="other">%s días</item>
</plurals>
<string name="quick_connect">Conexión Rápida</string>
<string name="quick_connect_summary">Autoriza a otro dispositivo para acceder a tu cuenta</string>
<string name="quick_connect_code">Introduce el código de Conexión Rápida</string>
<string name="quick_connect_code_error">El código debe tener 6 dígitos</string>
<string name="quick_connect_success">Dispositivo autorizado con éxito</string>
<string name="hdr_subtitle_style">Estilo de subtítulos HDR</string>
<string name="image_subtitle_opacity">Opacidad de los subtítulos</string>
<string name="brightness">Brillo</string>
<string name="contrast">Contraste</string>
<string name="saturation">Saturación</string>
<string name="hue">Tono de color</string>
<string name="red">Rojo</string>
<string name="green">Verde</string>
<string name="blue">Azul</string>
<string name="blur">Desenfoque</string>
<string name="save_for_album">Guardar en el álbum</string>
<string name="play_slideshow">Reproducir presentación</string>
<string name="stop_slideshow">Detener presentación</string>
<string name="slideshow_at_beginning">Presentación inicial</string>
<string name="no_more_images">No hay más imágenes</string>
<string name="rotate_left">Girar a la izquierda</string>
<string name="rotate_right">Girar a la derecha</string>
<string name="zoom_in">Acercar</string>
<string name="zoom_out">Alejar</string>
<string name="slideshow_duration">Duración de la presentación</string>
<string name="play_videos_during_slideshow">Reproducir vídeos durante la presentación</string>
<string name="send_media_info_log_to_server">Enviar registro de medios al servidor</string>
<string name="no_limit">Sin límite</string>
<string name="max_days_next_up">Límite de días en \"Siguiente\"</string>
<string name="add_row">Añadir fila</string>
<string name="genres_in">Géneros de %1$s</string>
<string name="height">Altura</string>
<string name="apply_all_rows">Aplicar a todas las filas</string>
<string name="customize_home">Personalizar página de inicio</string>
<string name="home_rows">Filas de inicio</string>
<string name="load_from_server">Cargar desde el servidor</string>
<string name="save_to_server">Guardar en el servidor</string>
<string name="load_from_web_client">Cargar del cliente web</string>
<string name="use_series">Usar imágenes de la serie</string>
<string name="add_row_for">Agregar fila para %1$s</string>
<string name="overwrite_server_settings">¿Sobrescribir ajustes en el servidor?</string>
<string name="overwrite_local_settings">¿Sobrescribir ajustes locales?</string>
<string name="for_episodes">Para episodios</string>
<string name="suggestions_for">Sugerencias para %1$s</string>
<string name="increase_all_cards_size">Aumentar tamaño de todas las tarjetas</string>
<string name="decrease_all_cards_size">Reducir tamaño de todas las tarjetas</string>
<string name="use_thumb_images">Usar miniaturas</string>
<string name="settings_saved">Ajustes guardados</string>
<string name="display_presets">Preajustes de visualización</string>
<string name="display_presets_description">Preajustes integrados para estilizar todas las filas</string>
<string name="customize_home_summary">Elegir filas e imágenes en la página de inicio</string>
<string name="recently_released_in">Lanzado recientemente en %1$s</string>
</resources>

Some files were not shown because too many files have changed in this diff Show more