diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index db31b32e..0b0f2204 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b63017b5..86c17c4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/README.md b/README.md index b4121cb9..1b3a0708 100644 --- a/README.md +++ b/README.md @@ -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 0 3 0_movies diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 29c36ac2..366dfcd1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } } } diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json new file mode 100644 index 00000000..b719e5f4 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json @@ -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')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index dcc25af0..b7f4b1d3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index a3629843..b3f33bed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt deleted file mode 100644 index a724930d..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt +++ /dev/null @@ -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 { - 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): List { - 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.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index f66dcfca..f013525b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 2d8c7baa..950e9b40 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -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?, +) = Destination.FilteredCollection( + itemId = parentId, + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + genreName, + parentName, + ).joinToString(" "), + filter = + GetItemsFilter( + genres = listOf(genreId), + includeItemTypes = includeItemTypes, + ), + useSavedLibraryDisplayInfo = false, + ), + recursive = true, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt index 1bcacb3f..3444ac54 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt @@ -31,6 +31,7 @@ data class Chapter( ) }, ) - }.orEmpty() + }?.sortedBy { it.position } + .orEmpty() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt new file mode 100644 index 00000000..09622733 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -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, + 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, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt index f3f52585..2b503023 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt @@ -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, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 4d7dd76f..709abd6c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -656,6 +656,13 @@ sealed interface AppPreference { setter = { prefs, _ -> prefs }, ) + val CustomizeHome = + AppDestinationPreference( + title = R.string.customize_home, + destination = Destination.HomeSettings, + summary = R.string.customize_home_summary, + ) + val SendCrashReports = AppSwitchPreference( title = R.string.send_crash_reports, @@ -943,6 +950,14 @@ sealed interface AppPreference { setter = { prefs, _ -> prefs }, ) + val QuickConnect = + AppClickablePreference( + title = R.string.quick_connect, + summary = R.string.quick_connect_summary, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + val SlideshowDuration = AppSliderPreference( 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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt index a64fc9d9..e082c5fa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -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) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt new file mode 100644 index 00000000..b8a850ef --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -0,0 +1,1024 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +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.SUPPORTED_HOME_PAGE_SETTINGS_VERSION +import com.github.damontecres.wholphin.data.model.createGenreDestination +import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration +import com.github.damontecres.wholphin.preferences.HomePagePreferences +import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.components.getGenreImageMap +import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions +import com.github.damontecres.wholphin.ui.playback.getTypeFor +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.GetGenresRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error +import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success +import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToStream +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.UUID +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.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.request.GetGenresRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest +import org.jellyfin.sdk.model.api.request.GetRecordingsRequest +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HomeSettingsService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + private val navDrawerService: NavDrawerService, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + private val suggestionService: SuggestionService, + ) { + @OptIn(ExperimentalSerializationApi::class) + val jsonParser = + Json { + isLenient = true + ignoreUnknownKeys = true + allowTrailingComma = true + } + + val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY) + + /** + * Saves a [HomePageSettings] to the server for the user under the display preference ID + * + * @see loadFromServer + */ + suspend fun saveToServer( + userId: UUID, + settings: HomePageSettings, + displayPreferencesId: String = DISPLAY_PREF_ID, + ) { + val current = getDisplayPreferences(userId, DISPLAY_PREF_ID) + val customPrefs = + current.customPrefs.toMutableMap().apply { + put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings)) + } + api.displayPreferencesApi.updateDisplayPreferences( + displayPreferencesId = displayPreferencesId, + userId = userId, + client = context.getString(R.string.app_name), + data = current.copy(customPrefs = customPrefs), + ) + } + + /** + * Reads a [HomePageSettings] from the server for the user and display preference ID + * + * Returns null if there is none saved + * + * @see saveToServer + */ + suspend fun loadFromServer( + userId: UUID, + displayPreferencesId: String = DISPLAY_PREF_ID, + ): HomePageSettings? { + val current = getDisplayPreferences(userId, displayPreferencesId) + return current.customPrefs[CUSTOM_PREF_ID]?.let { + val jsonElement = jsonParser.parseToJsonElement(it) + decode(jsonElement) + } + } + + private suspend fun getDisplayPreferences( + userId: UUID, + displayPreferencesId: String, + ) = api.displayPreferencesApi + .getDisplayPreferences( + userId = userId, + displayPreferencesId = displayPreferencesId, + client = context.getString(R.string.app_name), + ).content + + /** + * Computes the filename for locally saved [HomePageSettings] + */ + private fun filename(userId: UUID) = "${CUSTOM_PREF_ID}_${userId.toServerString()}.json" + + /** + * Save the [HomePageSettings] for the user locally on the device + * + * @see loadFromLocal + */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun saveToLocal( + userId: UUID, + settings: HomePageSettings, + ) { + val dir = File(context.filesDir, CUSTOM_PREF_ID) + dir.mkdirs() + File(dir, filename(userId)).outputStream().use { + jsonParser.encodeToStream(settings, it) + } + } + + /** + * Reads [HomePageSettings] for the user if it exists + * + * @see saveToLocal + */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun loadFromLocal(userId: UUID): HomePageSettings? { + val dir = File(context.filesDir, CUSTOM_PREF_ID) + val file = File(dir, filename(userId)) + return if (file.exists()) { + val fileContents = file.readText() + val jsonElement = jsonParser.parseToJsonElement(fileContents) + decode(jsonElement) + } else { + null + } + } + + /** + * Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows + * + * This is public only for testing + */ + fun decode(element: JsonElement): HomePageSettings { + val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull + if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) { + throw UnsupportedHomeSettingsVersionException(version) + } + val rowsElement = element.jsonObject["rows"]?.jsonArray + val rows = + rowsElement + ?.mapNotNull { row -> + try { + jsonParser.decodeFromJsonElement(row) + } catch (ex: Exception) { + Timber.w(ex, "Unknown row %s", row) + // TODO maybe use placeholder instead of null? + null + } + }.orEmpty() + return HomePageSettings(rows, version) + } + + /** + * Loads [HomePageSettings] into [currentSettings] + * + * First checks locally, then on the server, and finally creates a default if needed + * + * Does not persist either the server nor default + */ + suspend fun loadCurrentSettings(userId: UUID) { + Timber.v("Getting setting for %s", userId) + // User local then server/remote otherwise create a default + val settings = + try { + val local = loadFromLocal(userId) + Timber.v("Found local? %s", local != null) + local + } catch (ex: Exception) { + Timber.w(ex, "Error loading local settings") + // TODO show toast? + null + } ?: try { + val remote = loadFromServer(userId) + Timber.v("Found remote? %s", remote != null) + remote + } catch (ex: Exception) { + Timber.w(ex, "Error loading remote settings") + null + } + val resolvedSettings = + if (settings != null) { + Timber.v("Found settings") + // Resolve + val resolvedRows = + settings.rows.mapIndexed { index, config -> + resolve(index, config) + } + HomePageResolvedSettings(resolvedRows) + } else { + createDefault(userId) + } + + currentSettings.update { resolvedSettings } + } + + suspend fun updateCurrent(settings: HomePageSettings) { + val resolvedRows = + settings.rows.mapIndexed { index, config -> + resolve(index, config) + } + val resolvedSettings = HomePageResolvedSettings(resolvedRows) + currentSettings.update { resolvedSettings } + } + + /** + * Create a default [HomePageResolvedSettings] using the available libraries + */ + suspend fun createDefault(userId: UUID): HomePageResolvedSettings { + Timber.v("Creating default settings") + val user = serverRepository.currentUser.value?.takeIf { it.id == userId } + val userDto = serverRepository.currentUserDto.value?.takeIf { it.id == userId } + val libraries = + if (user != null) { + navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false) + } else { + navDrawerService.getAllUserLibraries(userId, userDto?.tvAccess ?: false) + } + + val prefs = + userPreferencesService.getCurrent().appPreferences.homePagePreferences + + val includedIds = + libraries + .mapIndexed { index, it -> + val parentId = it.itemId + val title = getRecentlyAddedTitle(context, it) + if (it.collectionType == CollectionType.LIVETV) { + HomeRowConfigDisplay( + id = index, + title = context.getString(R.string.live_tv), + config = HomeRowConfig.TvPrograms(), + ) + } else { + HomeRowConfigDisplay( + id = index, + title = title, + config = HomeRowConfig.RecentlyAdded(parentId), + ) + } + } + val continueWatchingRows = + if (prefs.combineContinueNext) { + listOf( + HomeRowConfigDisplay( + id = includedIds.size + 1, + title = context.getString(R.string.combine_continue_next), + config = HomeRowConfig.ContinueWatchingCombined(), + ), + ) + } else { + listOf( + HomeRowConfigDisplay( + id = includedIds.size + 1, + title = context.getString(R.string.continue_watching), + config = HomeRowConfig.ContinueWatching(), + ), + HomeRowConfigDisplay( + id = includedIds.size + 2, + title = context.getString(R.string.next_up), + config = HomeRowConfig.NextUp(), + ), + ) + } + val rowConfig = continueWatchingRows + includedIds + return HomePageResolvedSettings(rowConfig) + } + + suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? { + val customPrefs = + api.displayPreferencesApi + .getDisplayPreferences( + displayPreferencesId = "usersettings", + userId = userId, + client = "emby", + ).content.customPrefs + val userDto by api.userApi.getUserById(userId) + val config = userDto.configuration ?: DefaultUserConfiguration + val libraries = + api.userViewsApi + .getUserViews(userId = userId) + .content.items + .filter { + it.collectionType in supportedHomeCollectionTypes && + it.id !in config.latestItemsExcludes + } + + return if (customPrefs.isNotEmpty()) { + var id = 0 + val rowConfigs = + (0..9) + .mapNotNull { idx -> + val sectionType = + HomeSectionType.fromString(customPrefs["homesection$idx"]?.lowercase()) + Timber.v( + "sectionType=$sectionType, %s", + customPrefs["homesection$idx"]?.lowercase(), + ) + val config = + when (sectionType) { + HomeSectionType.ACTIVE_RECORDINGS -> { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.active_recordings), + config = HomeRowConfig.Recordings(), + ) + } + + HomeSectionType.RESUME -> { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.continue_watching), + config = HomeRowConfig.ContinueWatching(), + ) + } + + HomeSectionType.NEXT_UP -> { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.next_up), + config = HomeRowConfig.NextUp(), + ) + } + + HomeSectionType.LIVE_TV -> { + if (userDto.tvAccess) { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.live_tv), + config = HomeRowConfig.TvPrograms(), + ) + } else { + null + } + } + + HomeSectionType.LATEST_MEDIA -> { + // Handled below + null + } + + // Unsupported + HomeSectionType.RESUME_AUDIO, + HomeSectionType.RESUME_BOOK, + -> { + null + } + + HomeSectionType.SMALL_LIBRARY_TILES, + HomeSectionType.LIBRARY_BUTTONS, + HomeSectionType.NONE, + null, + -> { + null + } + } + if (sectionType == HomeSectionType.LATEST_MEDIA) { + libraries.map { + HomeRowConfigDisplay( + id = id++, + title = + context.getString( + R.string.recently_added_in, + it.name ?: "", + ), + config = HomeRowConfig.RecentlyAdded(it.id), + ) + } + } else if (config != null) { + listOf(config) + } else { + null + } + }.flatten() + HomePageResolvedSettings(rowConfigs) + } else { + null + } + } + + /** + * Converts a [HomeRowConfig] into [HomeRowConfigDisplay] for UI purposes + */ + suspend fun resolve( + id: Int, + config: HomeRowConfig, + ): HomeRowConfigDisplay = + when (config) { + is HomeRowConfig.ByParent -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + name, + config, + ) + } + + is HomeRowConfig.ContinueWatching -> { + HomeRowConfigDisplay( + id, + context.getString(R.string.continue_watching), + config, + ) + } + + is HomeRowConfig.ContinueWatchingCombined -> { + HomeRowConfigDisplay( + id, + context.getString(R.string.combine_continue_next), + config, + ) + } + + is HomeRowConfig.Genres -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + context.getString(R.string.genres_in, name), + config, + ) + } + + is HomeRowConfig.GetItems -> { + HomeRowConfigDisplay(id, config.name, config) + } + + is HomeRowConfig.NextUp -> { + HomeRowConfigDisplay( + id, + context.getString(R.string.next_up), + config, + ) + } + + is HomeRowConfig.RecentlyAdded -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + context.getString(R.string.recently_added_in, name), + config, + ) + } + + is HomeRowConfig.RecentlyReleased -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + context.getString(R.string.recently_released_in, name), + config, + ) + } + + is HomeRowConfig.Favorite -> { + val name = + context.getString( + R.string.favorite_items, + context.getString(favoriteOptions[config.kind]!!), + ) + HomeRowConfigDisplay(id, name, config) + } + + is HomeRowConfig.Recordings -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.active_recordings), + config, + ) + } + + is HomeRowConfig.TvPrograms -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.live_tv), + config, + ) + } + + is HomeRowConfig.TvChannels -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.channels), + config, + ) + } + + is HomeRowConfig.Suggestions -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.suggestions_for, name), + config, + ) + } + } + + /** + * Fetch the data from the server for a given [HomeRowConfig] + */ + suspend fun fetchDataForRow( + row: HomeRowConfig, + scope: CoroutineScope, + prefs: HomePagePreferences, + userDto: UserDto, + libraries: List, + limit: Int = prefs.maxItemsPerRow, + isRefresh: Boolean, + ): HomeRowLoadingState = + when (row) { + is HomeRowConfig.ContinueWatching -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + + Success( + title = context.getString(R.string.continue_watching), + items = resume, + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.NextUp -> { + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + + Success( + title = context.getString(R.string.next_up), + items = nextUp, + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.ContinueWatchingCombined -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + + Success( + title = context.getString(R.string.continue_watching), + items = + latestNextUpService.buildCombined( + resume, + nextUp, + ), + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.Genres -> { + val request = + GetGenresRequest( + parentId = row.parentId, + userId = userDto.id, + limit = limit, + ) + val items = + GetGenresRequestHandler + .execute(api, request) + .content.items + val genreIds = items.map { it.id } + val genreImages = + getGenreImageMap( + api = api, + userId = serverRepository.currentUser.value?.id, + scope = scope, + imageUrlService = imageUrlService, + genres = genreIds, + parentId = row.parentId, + includeItemTypes = null, + cardWidthPx = null, + useCache = isRefresh, + ) + val library = + libraries + .firstOrNull { it.itemId == row.parentId } + + val title = + library?.name?.let { context.getString(R.string.genres_in, it) } + ?: context.getString(R.string.genres) + val genres = + items.map { + BaseItem( + it, + false, + genreImages[it.id], + createGenreDestination( + genreId = it.id, + genreName = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = + library?.collectionType?.let { + getTypeFor(it)?.let { + listOf(it) + } + }, + ), + ) + } + + Success( + title, + genres, + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.RecentlyAdded -> { + val library = + libraries + .firstOrNull { it.itemId == row.parentId } + val title = getRecentlyAddedTitle(context, library) + val request = + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = row.parentId, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) } + .let { + Success( + title, + it, + row.viewOptions, + ) + } + latest + } + + is HomeRowConfig.RecentlyReleased -> { + val name = + libraries + .firstOrNull { it.itemId == row.parentId } + ?.name + val title = + name?.let { + context.getString(R.string.recently_released_in, it) + } ?: context.getString(R.string.recently_released) + val request = + GetItemsRequest( + parentId = row.parentId, + limit = limit, + sortBy = listOf(ItemSortBy.PREMIERE_DATE), + sortOrder = listOf(SortOrder.DESCENDING), + fields = DefaultItemFields, + recursive = true, + ) + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) } + .let { + Success( + title, + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.ByParent -> { + val request = + GetItemsRequest( + userId = userDto.id, + parentId = row.parentId, + recursive = row.recursive, + sortBy = row.sort?.let { listOf(it.sort) }, + sortOrder = row.sort?.let { listOf(it.direction) }, + limit = limit, + fields = DefaultItemFields, + ) + val name = + api.userLibraryApi + .getItem(itemId = row.parentId) + .content.name + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + name ?: context.getString(R.string.collection), + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.GetItems -> { + val request = + row.getItems.let { + if (it.limit == null) { + it.copy( + userId = userDto.id, + limit = limit, + ) + } else { + it.copy( + userId = userDto.id, + ) + } + } + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + row.name, + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.Favorite -> { + val title = + context.getString( + R.string.favorite_items, + context.getString(favoriteOptions[row.kind]!!), + ) + if (row.kind == BaseItemKind.PERSON) { + val request = + GetPersonsRequest( + userId = userDto.id, + limit = limit, + fields = DefaultItemFields, + isFavorite = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY), + ) + GetPersonsHandler + .execute(api, request) + .content.items + .map { BaseItem(it, true) } + .let { + Success( + title, + it, + row.viewOptions, + ) + } + } else { + val request = + GetItemsRequest( + userId = userDto.id, + recursive = true, + limit = limit, + fields = DefaultItemFields, + includeItemTypes = listOf(row.kind), + isFavorite = true, + ) + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + title, + it, + row.viewOptions, + ) + } + } + } + + is HomeRowConfig.Recordings -> { + val request = + GetRecordingsRequest( + userId = userDto.id, + isInProgress = true, + fields = DefaultItemFields, + limit = limit, + enableImages = true, + enableUserData = true, + ) + api.liveTvApi + .getRecordings(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + context.getString(R.string.active_recordings), + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.TvPrograms -> { + val request = + GetRecommendedProgramsRequest( + userId = userDto.id, + fields = DefaultItemFields, + limit = limit, + enableUserData = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY), + imageTypeLimit = 1, + ) + api.liveTvApi + .getRecommendedPrograms(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + context.getString(R.string.live_tv), + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.TvChannels -> { + api.liveTvApi + .getLiveTvChannels( + userId = userDto.id, + fields = DefaultItemFields, + limit = limit, + enableImages = true, + ).toBaseItems(api, row.viewOptions.useSeries) + .let { + Success( + context.getString(R.string.channels), + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.Suggestions -> { + val library = + api.userLibraryApi + .getItem(itemId = row.parentId) + .content + val title = context.getString(R.string.suggestions_for, library.name ?: "") + val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) + val suggestions = + itemKind?.let { + suggestionService + .getSuggestionsFlow(row.parentId, itemKind) + .firstOrNull() + } + if (suggestions != null && suggestions is SuggestionsResource.Success) { + Success( + title, + suggestions.items, + row.viewOptions, + ) + } else if (suggestions is SuggestionsResource.Empty) { + Success( + title, + listOf(), + row.viewOptions, + ) + } else { + Error( + title, + message = "Unsupported type ${library.collectionType}", + ) + } + } + } + + companion object { + const val DISPLAY_PREF_ID = "default" + const val CUSTOM_PREF_ID = "home_settings" + } + } + +/** + * A [HomeRowConfig] with a resolved ID and title so it is usable in the UI + */ +data class HomeRowConfigDisplay( + val id: Int, + val title: String, + val config: HomeRowConfig, +) + +/** + * List of resolved [HomeRowConfig]s as [HomeRowConfigDisplay]s + * + * @see HomePageSettings + */ +data class HomePageResolvedSettings( + val rows: List, +) { + companion object { + val EMPTY = HomePageResolvedSettings(listOf()) + } +} + +// https://github.com/jellyfin/jellyfin/blob/v10.11.6/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs +enum class HomeSectionType( + val serialName: String, +) { + NONE("none"), + SMALL_LIBRARY_TILES("smalllibrarytitles"), + LIBRARY_BUTTONS("librarybuttons"), + ACTIVE_RECORDINGS("activerecordings"), + RESUME("resume"), + RESUME_AUDIO("resumeaudio"), + LATEST_MEDIA("latestmedia"), + NEXT_UP("nextup"), + LIVE_TV("livetv"), + RESUME_BOOK("resumebook"), + ; + + companion object { + fun fromString(homeKey: String?) = homeKey?.let { entries.firstOrNull { it.serialName == homeKey } } + } +} + +class UnsupportedHomeSettingsVersionException( + val unsupportedVersion: Int?, + val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, +) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") + +fun getRecentlyAddedTitle( + context: Context, + library: Library?, +): String = + if (library?.isRecordingFolder == true) { + context.getString(R.string.recently_recorded) + } else { + library?.name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 39967e24..f0de9d24 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -30,6 +30,9 @@ class ImageUrlService useSeriesForPrimary: Boolean, imageTags: Map, imageType: ImageType, + parentThumbId: UUID? = null, + parentBackdropId: UUID? = null, + backdropTags: List = 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, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index 6ac016a3..e30a39ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -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 { 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 { 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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt new file mode 100644 index 00000000..adb21ff7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -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 = _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 { + 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 { + 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() + val moreItems = mutableListOf() + 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, + val moreItems: List, + val discoverEnabled: Boolean, +) { + companion object { + val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false) + } +} + +val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index bc0c8f94..2ca3d1b1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -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 by lazy { - display.supportedModes - .orEmpty() - .map { DisplayMode(it) } - .sortedWith( - compareByDescending({ 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({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) + val currentDisplayMode = display.mode require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } val width = stream.width diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index f06a7529..78ff7d3b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -44,18 +44,33 @@ class SeerrServerRepository private val serverRepository: ServerRepository, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { - private val _current = MutableStateFlow(null) - val current: StateFlow = _current - val currentServer: Flow = current.map { it?.server } - val currentUser: Flow = current.map { it?.user } + private val _connection = + MutableStateFlow(SeerrConnectionStatus.NotConfigured) + val connection: StateFlow = _connection + + val current: Flow = + _connection.map { (it as? SeerrConnectionStatus.Success)?.current } + val currentServer: Flow = + connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } + val currentUser: Flow = + connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } /** * Whether Seerr integration is currently active of not */ - val active: Flow = current.map { it != null && seerrApi.active } + val active: Flow = + 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,41 +264,47 @@ class UserSwitchListener serverRepository.currentUser.asFlow().collect { user -> Timber.d("New user") seerrServerRepository.clear() + homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } if (user != null) { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .firstOrNull() - ?.let { seerrUser -> - val server = seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - 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 - } + // Check for home settings + launchIO { + homeSettingsService.loadCurrentSettings(user.id) + } + // Check for seerr server + launchIO { + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?.let { seerrUser -> + 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) { + login( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } else { + seerrApi.api.usersApi.authMeGet() + } + seerrServerRepository.set(server, seerrUser, userConfig) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server.url, ex) } - seerrServerRepository.set(server, seerrUser, userConfig) + } } - } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt index db6cef2c..0fe32179 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -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) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt index 5542ea51..0921a1be 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -60,15 +60,20 @@ class SuggestionsCache ): CachedSuggestions? { val key = cacheKey(userId, libraryId, itemKind) return memoryCache.getOrPut(key) { - try { - mutex.withLock { - File(cacheDir, "$key.json").inputStream().use { + mutex.withLock { + try { + val cacheFile = File(cacheDir, "$key.json") + if (!cacheFile.exists()) { + return@withLock null + } + + cacheFile.inputStream().use { json.decodeFromStream(it) } + } catch (ex: Exception) { + Timber.e(ex, "Exception reading from disk cache") + null } - } catch (ex: Exception) { - Timber.e(ex, "Exception reading from disk cache") - null } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index 1ee5870b..eab87f20 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -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 + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index f207562c..52c37413 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index d8816e71..03ea0478 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -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 { 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 = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 12854cd7..c997efd1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -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). * diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 1842620a..8f4066bb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -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) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 958f9dfd..2d9b6d5e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -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 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 70de419b..51d003fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -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, - ImageType.PRIMARY, - fillWidth = null, - fillHeight = fillHeight, - ) + item.imageUrlOverride + ?: imageUrlService.getItemImageUrl( + item, + 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), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt index 4e1d0e86..a7df0cb3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index e4ffa5ee..279ca7b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt index 73cf5ba3..a32fece9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index 7b653865..c191a70d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 7ebf0faf..314a7e1a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -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 ItemRow( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = state, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt index 47fd4e94..2ce33e09 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index e3e6059e..b066d372 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -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 { + 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 +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 6d20211f..4a71404a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -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,9 +166,10 @@ class CollectionFolderViewModel viewModelScope.launchIO { super.itemId = itemId try { - itemId.toUUIDOrNull()?.let { - fetchItem(it) - } + val item = + itemId.toUUIDOrNull()?.let { + fetchItem(it) + } val libraryDisplayInfo = serverRepository.currentUser.value?.let { user -> @@ -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,34 +266,32 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, useSeriesForPrimary: Boolean, - ) { - viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - if (resetState) { - loading.value = DataLoadingState.Loading - } - backgroundLoading.value = LoadingState.Loading - this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection - this@CollectionFolderViewModel.filter.value = filter + ) = viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + if (resetState) { + loading.value = DataLoadingState.Loading } - try { - val newPager = - createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() - if (newPager.isNotEmpty()) newPager.getBlocking(0) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Success(newPager) - backgroundLoading.value = LoadingState.Success - } - } catch (ex: Exception) { - Timber.e( - ex, - "Exception while loading data: sort=%s, filter=%s", - sortAndDirection, - filter, - ) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Error(ex) - } + backgroundLoading.value = LoadingState.Loading + this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection + this@CollectionFolderViewModel.filter.value = filter + } + try { + val newPager = + createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() + if (newPager.isNotEmpty()) newPager.getBlocking(0) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Success(newPager) + backgroundLoading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e( + ex, + "Exception while loading data: sort=%s, filter=%s", + sortAndDirection, + filter, + ) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Error(ex) } } } @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index d5897a6b..579d9a97 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -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, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt new file mode 100644 index 00000000..b04bf451 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt @@ -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() + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index cdf18969..e1e28f0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -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() - val semaphore = Semaphore(4) - genres - .map { genre -> - viewModelScope.async(Dispatchers.IO) { - semaphore.withPermit { - val item = - GetItemsRequestHandler - .execute( - api, - GetItemsRequest( - 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, - ) - } - } - } - }.awaitAll() + val genreToUrl = + getGenreImageMap( + api = api, + userId = serverRepository.currentUser.value?.id, + scope = viewModelScope, + imageUrlService = imageUrlService, + genres = genres.map { it.id }, + parentId = itemId, + includeItemTypes = includeItemTypes, + cardWidthPx = cardWidthPx, + ) 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>(8) { + expireAfterWriteDuration = 2.hours + } +} + +suspend fun getGenreImageMap( + api: ApiClient, + userId: UUID?, + scope: CoroutineScope, + imageUrlService: ImageUrlService, + genres: List, + parentId: UUID, + includeItemTypes: List?, + cardWidthPx: Int?, + useCache: Boolean = true, +): Map { + val key = GenreCacheKey(userId, parentId) + if (useCache) { + genreCache.getIfAvailable(key)?.let { + Timber.v("Got cached entry") + return it + } + } + val genreToUrl = ConcurrentHashMap() + 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,23 +280,12 @@ 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), - includeItemTypes = includeItemTypes, - ), - useSavedLibraryDisplayInfo = false, - ), - recursive = true, + createGenreDestination( + genreId = genre.id, + genreName = genre.name, + parentId = itemId, + parentName = item?.title, + includeItemTypes = includeItemTypes, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index 1e274de6..38591a67 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -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 -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index b0e86e3e..908574e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -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, - ) { - viewModelScope.launch(Dispatchers.IO) { + ): Deferred = + viewModelScope.async(Dispatchers.IO) { val titleStr = context.getString(title) val row = try { @@ -115,10 +116,7 @@ abstract class RecommendedViewModel( HomeRowLoadingState.Error(titleStr, null, ex) } update(title, row) - // TODO - loading.setValueOnMain(LoadingState.Success) } - } } @Composable @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index e44c72f7..403ff5fa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -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>() + 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,8 +222,17 @@ 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) { - loading.setValueOnMain(LoadingState.Success) + for (i in 0.. current.toMutableList().apply { set(rowTitles[title]!!, row) } } + return row } companion object { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 4e952033..3258930c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -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>() + 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,7 +270,14 @@ class RecommendedTvShowViewModel } if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { - loading.setValueOnMain(LoadingState.Success) + for (i in 0.. current.toMutableList().apply { set(rowTitles[title]!!, row) } } + return row } companion object { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 8893246c..ac72a144 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -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 CardGrid( }, columns: Int = 6, spacing: Dp = 16.dp, + bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current, ) { val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) @@ -269,100 +273,102 @@ fun CardGrid( Box( modifier = Modifier.weight(1f), ) { - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(spacing), - state = gridState, - contentPadding = PaddingValues(vertical = 16.dp), - modifier = - Modifier - .fillMaxSize() - .focusGroup() - .focusRestorer(firstFocus) - .focusProperties { - onExit = { - // Leaving the grid, so "forget" the position + CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(spacing), + state = gridState, + contentPadding = PaddingValues(vertical = 16.dp), + modifier = + Modifier + .fillMaxSize() + .focusGroup() + .focusRestorer(firstFocus) + .focusProperties { + onExit = { + // Leaving the grid, so "forget" the position // focusedIndex = -1 - } - onEnter = { - if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { - focusedIndex = startPosition } - } - }, - ) { - items(pager.size) { index -> - val mod = - if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { - if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") - Modifier - .focusRequester(firstFocus) - .focusRequester(gridFocusRequester) - .focusRequester(alphabetFocusRequester) - } else { - Modifier - } - val item = pager[index] - cardContent( - item, - { - if (item != null) { - focusedIndex = index - onClickItem.invoke(index, item) - } - }, - { if (item != null) onLongClickItem.invoke(index, item) }, - mod - .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) - .onFocusChanged { focusState -> - if (DEBUG) { - Timber.v( - "$index isFocused=${focusState.isFocused}", - ) + onEnter = { + if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { + focusedIndex = startPosition + } } - if (focusState.isFocused) { - // Focused, so set that up - focusOn(index) - positionCallback?.invoke(columns, index) - } else if (focusedIndex == index) { + }, + ) { + items(pager.size) { index -> + val mod = + if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { + if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") + Modifier + .focusRequester(firstFocus) + .focusRequester(gridFocusRequester) + .focusRequester(alphabetFocusRequester) + } else { + Modifier + } + val item = pager[index] + cardContent( + item, + { + if (item != null) { + focusedIndex = index + onClickItem.invoke(index, item) + } + }, + { if (item != null) onLongClickItem.invoke(index, item) }, + mod + .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) + .onFocusChanged { focusState -> + if (DEBUG) { + Timber.v( + "$index isFocused=${focusState.isFocused}", + ) + } + if (focusState.isFocused) { + // Focused, so set that up + focusOn(index) + positionCallback?.invoke(columns, index) + } else if (focusedIndex == index) { // savedFocusedIndex = index // // Was focused on this, so mark unfocused // focusedIndex = -1 - } - }, - ) + } + }, + ) + } } - } - if (pager.isEmpty()) { + if (pager.isEmpty()) { // focusedIndex = -1 - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.no_results), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.align(Alignment.Center), - ) + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.no_results), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.align(Alignment.Center), + ) + } } - } - if (showFooter) { - // Footer - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .background(AppColors.TransparentBlack50), - ) { - val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" + if (showFooter) { + // Footer + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .background(AppColors.TransparentBlack50), + ) { + val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" // if (focusedIndex >= 0) { // focusedIndex + 1 // } else { // max(savedFocusedIndex, focusedIndexOnExit) + 1 // } - Text( - modifier = Modifier.padding(4.dp), - color = MaterialTheme.colorScheme.onBackground, - text = "$index / ${pager.size}", - ) + Text( + modifier = Modifier.padding(4.dp), + color = MaterialTheme.colorScheme.onBackground, + text = "$index / ${pager.size}", + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index fd4d46d3..4ddc41c1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -65,7 +65,7 @@ fun CollectionFolderPhotoAlbum( } else { item.destination(index) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) }, itemId = itemId.toServerString(), initialFilter = filter, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 0abcdc07..cfae65ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -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>(listOf()) val logcat = MutableLiveData>(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(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 7df86615..0c7d08b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -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 -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index cc80d404..a50e848d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 64748bd0..3c036534 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -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 -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index bac9f51a..63d5ee5d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -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 -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 23ed08c3..2a40a399 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -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 -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt new file mode 100644 index 00000000..6717527e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt @@ -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(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), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt new file mode 100644 index 00000000..baa98ef1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt @@ -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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index ff0ddade..aab6e056 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 55335057..579a3866 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index a8a9d83f..8fba8ed6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -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 -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 86e2b398..a0d120e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -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(), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 691b00cb..97b974ff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -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,28 +255,41 @@ fun SeerrDiscoverPage( .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) .fillMaxHeight(.25f), ) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), - modifier = - Modifier - .focusRestorer() - .fillMaxSize(), + 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), ) { - itemsIndexed(rows) { rowIndex, row -> - DiscoverRow( - row = row, - onClickItem = { index, item -> - position = RowColumn(rowIndex, index) - viewModel.navigationManager.navigateTo(item.destination) - }, - onLongClickItem = { index, item -> }, - onCardFocus = { index -> position = RowColumn(rowIndex, index) }, - focusRequester = focusRequesters[rowIndex], - modifier = - Modifier - .fillMaxWidth(), - ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), + modifier = + Modifier + .focusRestorer() + .fillMaxSize(), + ) { + itemsIndexed(rows) { rowIndex, row -> + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + DiscoverRow( + row = row, + onClickItem = { index, item -> + position = RowColumn(rowIndex, index) + viewModel.navigationManager.navigateTo(item.destination) + }, + onLongClickItem = { index, item -> }, + onCardFocus = { index -> position = RowColumn(rowIndex, index) }, + focusRequester = focusRequesters[rowIndex], + modifier = + Modifier + .fillMaxWidth(), + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt index 3a838815..85863650 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -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) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index cfb13ff7..cf8738c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -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(null) } var showPlaylistDialog by remember { mutableStateOf(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,43 +194,48 @@ fun HomePage( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun HomePageContent( homeRows: List, + 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) } - LaunchedEffect(homeRows) { - if (!firstFocused && homeRows.isNotEmpty()) { - if (position.row >= 0) { - val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) - rowFocusRequesters.getOrNull(index)?.tryRequestFocus() - firstFocused = true - } else { - // Waiting for the first home row to load, then focus on it - homeRows - .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } - ?.let { - rowFocusRequesters[it].tryRequestFocus() - firstFocused = true - delay(50) - listState.scrollToItem(it) - } + if (takeFocus) { + LaunchedEffect(homeRows) { + if (!firstFocused && homeRows.isNotEmpty()) { + if (position.row >= 0) { + val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) + rowFocusRequesters.getOrNull(index)?.tryRequestFocus() + firstFocused = true + } else { + // Waiting for the first home row to load, then focus on it + homeRows + .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } + ?.let { + rowFocusRequesters[it].tryRequestFocus() + firstFocused = true + delay(50) + listState.scrollToItem(it) + } + } } } } @@ -240,134 +256,110 @@ fun HomePageContent( .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .fillMaxHeight(.33f), ) - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = - PaddingValues( - bottom = Cards.height2x3, - ), - modifier = - Modifier - .focusRestorer(), + 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), ) { - itemsIndexed(homeRows) { rowIndex, row -> - when (val r = row) { - is HomeRowLoadingState.Loading, - is HomeRowLoadingState.Pending, - -> { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - 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, - ) - } - } + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = + PaddingValues( + bottom = Cards.height2x3, + ), + modifier = + Modifier + .focusRestorer(), + ) { + itemsIndexed(homeRows) { rowIndex, row -> + CompositionLocalProvider( + LocalBringIntoViewSpec provides defaultBringIntoViewSpec, + ) { + when (val r = row) { + is HomeRowLoadingState.Loading, + is HomeRowLoadingState.Pending, + -> { + FocusableItemRow( + title = r.title, + subtitle = stringResource(R.string.loading), + modifier = Modifier.animateItem(), + ) + } - 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 + is HomeRowLoadingState.Error -> { + FocusableItemRow( + title = r.title, + subtitle = r.localizedMessage, + isError = true, + modifier = Modifier.animateItem(), + ) + } + + is HomeRowLoadingState.Success -> { + if (row.items.isNotEmpty()) { + val viewOptions = row.viewOptions + ItemRow( + title = row.title, + items = row.items, + onClickItem = { index, item -> + onClickItem.invoke(RowColumn(rowIndex, index), item) + }, + onLongClickItem = { index, item -> + onLongClickItem.invoke( + RowColumn(rowIndex, index), + item, + ) }, - ).animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = r.localizedMessage, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } - - is HomeRowLoadingState.Success -> { - if (row.items.isNotEmpty()) { - ItemRow( - title = row.title, - items = row.items, - onClickItem = { index, item -> - onClickItem.invoke(RowColumn(rowIndex, index), item) - }, - onLongClickItem = { index, item -> - onLongClickItem.invoke(RowColumn(rowIndex, index), item) - }, - modifier = - Modifier - .fillMaxWidth() - .focusGroup() - .focusRequester(rowFocusRequesters[rowIndex]) - .animateItem(), - cardContent = { index, item, cardModifier, onClick, onLongClick -> - BannerCard( - name = item?.data?.seriesName ?: item?.name, - 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, 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, - ), - ) - } - }.onKeyEvent { - if (isPlayKeyUp(it) && item?.type?.playable == true) { - Timber.v("Clicked play on ${item.id}") - onClickPlay.invoke(position, item) - return@onKeyEvent true - } - return@onKeyEvent false - }, - interactionSource = null, - cardHeight = Cards.height2x3, + Modifier + .fillMaxWidth() + .focusGroup() + .focusRequester(rowFocusRequesters[rowIndex]) + .animateItem(), + horizontalPadding = viewOptions.spacing.dp, + cardContent = { index, item, cardModifier, onClick, onLongClick -> + HomePageCardContent( + index = index, + item = item, + onClick = onClick, + onLongClick = onLongClick, + viewOptions = viewOptions, + modifier = + cardModifier + .onFocusChanged { + if (it.isFocused) { + onFocusPosition?.invoke( + RowColumn(rowIndex, index), + ) + } + }.onKeyEvent { + if (isPlayKeyUp(it) && item?.type?.playable == true) { + Timber.v("Clicked play on ${item.id}") + onClickPlay.invoke( + position, + item, + ) + return@onKeyEvent true + } + return@onKeyEvent false + }, + ) + }, ) - }, - ) + } 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, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 0e571544..a8ce7fd9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -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.Pending) - val refreshState = MutableLiveData(LoadingState.Pending) - val watchingRows = MutableLiveData>(listOf()) - val latestRows = MutableLiveData>(listOf()) - - private lateinit var preferences: UserPreferences + private val _state = MutableStateFlow(HomeState.EMPTY) + val state: StateFlow = _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, - ), - ) - } - if (nextUp.isNotEmpty()) { - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.next_up), - items = nextUp, - ), - ) + 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 + } + 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) + } + } + it.copy( + loadingState = LoadingState.Success, + homeRows = newRows, + ) } - loadingState.value = LoadingState.Success } - refreshState.setValueOnMain(LoadingState.Success) - val loadedLatest = latestNextUpService.loadLatest(latest) - this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) + 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, + 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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt new file mode 100644 index 00000000..4236c03a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -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 { + 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), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt new file mode 100644 index 00000000..eaf44d81 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -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]), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt new file mode 100644 index 00000000..7ac43548 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt @@ -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>, + 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 + 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( + 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( + title = R.string.spacing, + defaultValue = 16, + min = 0, + max = 32, + interval = 2, + getter = { it.spacing.toLong() }, + setter = { prefs, value -> prefs.copy(spacing = value.toInt()) }, + ) + + val ViewOptionsContentScale = + AppChoicePreference( + title = R.string.global_content_scale, + defaultValue = PrefContentScale.FIT, + displayValues = R.array.content_scale, + getter = { it.contentScale }, + setter = { viewOptions, value -> viewOptions.copy(contentScale = value) }, + indexToValue = { PrefContentScale.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val ViewOptionsAspectRatio = + AppChoicePreference( + title = R.string.aspect_ratio, + defaultValue = AspectRatio.TALL, + displayValues = R.array.aspect_ratios, + getter = { it.aspectRatio }, + setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) }, + indexToValue = { AspectRatio.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsShowTitles = + AppSwitchPreference( + title = R.string.show_titles, + defaultValue = true, + getter = { it.showTitles }, + setter = { vo, value -> vo.copy(showTitles = value) }, + ) + + val ViewOptionsUseSeries = + AppSwitchPreference( + title = R.string.use_series, + defaultValue = true, + getter = { it.useSeries }, + setter = { vo, value -> vo.copy(useSeries = value) }, + ) + + val ViewOptionsImageType = + AppChoicePreference( + title = R.string.image_type, + defaultValue = ViewOptionImageType.PRIMARY, + displayValues = R.array.image_types, + getter = { it.imageType }, + setter = { viewOptions, value -> + val aspectRatio = + when (value) { + ViewOptionImageType.PRIMARY -> AspectRatio.TALL + ViewOptionImageType.THUMB -> AspectRatio.WIDE + } + viewOptions.copy(imageType = value, aspectRatio = aspectRatio) + }, + indexToValue = { ViewOptionImageType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsApplyAll = + AppClickablePreference( + title = R.string.apply_all_rows, + ) + + val ViewOptionsReset = + AppClickablePreference( + title = R.string.reset, + ) + + val ViewOptionsUseThumb = + AppClickablePreference( + title = R.string.use_thumb_images, + ) + + val ViewOptionsEpisodeContentScale = + AppChoicePreference( + 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( + 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( + 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, + ), + ), + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt new file mode 100644 index 00000000..97249aa4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt @@ -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, + 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), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt new file mode 100644 index 00000000..ad433c23 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt @@ -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 +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt new file mode 100644 index 00000000..3bac590a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt @@ -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() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt new file mode 100644 index 00000000..e4b61881 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt new file mode 100644 index 00000000..4f265dbb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt @@ -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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt new file mode 100644 index 00000000..1185f719 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -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(null) } + var searchForDialog by remember { mutableStateOf(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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt new file mode 100644 index 00000000..9eadc615 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -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), + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt new file mode 100644 index 00000000..67b57e28 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -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, + @param:IoCoroutineScope private val ioScope: CoroutineScope, + ) : ViewModel() { + private val _state = MutableStateFlow(HomePageSettingsState.EMPTY) + val state: StateFlow = _state + + private var idCounter by Delegates.notNull() + + 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 List.move( + direction: MoveDirection, + index: Int, + ): List = + 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() + + 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, + val rowData: List, + val libraries: List, +) { + 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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 6d793ed4..936ce69f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index e94e6b38..87ec465e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -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) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 37d9b250..f0276ffd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -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>(null) - val libraries = MutableLiveData>(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, + ) + } + + NavDrawerItem.More -> { + setShowMore(!moreExpanded.value!!) + } + + NavDrawerItem.Discover -> { + setIndex(index) + navigationManager.navigateToFromDrawer( + Destination.Discover, + ) + } + + is ServerNavDrawerItem -> { + setIndex(index) + navigationManager.navigateToFromDrawer(item.destination) + } + } } - fun init() { - viewModelScope.launchIO { - val all = navDrawerItemRepository.getNavDrawerItems() - val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) - val moreLibraries = all.toMutableList().apply { removeAll(libraries) } + fun setIndex(index: Int) { + selectedIndex.value = index + } - withContext(Dispatchers.Main) { - this@NavDrawerViewModel.moreLibraries.value = moreLibraries - this@NavDrawerViewModel.libraries.value = libraries - } + 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,51 +393,83 @@ fun NavDrawer( ), ) } - itemsIndexed(libraries) { index, it -> - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = it, - selected = selectedIndex == index, - moreExpanded = showMore, - drawerOpen = isOpen, - interactionSource = interactionSource, - onClick = { - viewModel.onClickDrawerItem(index, it) - }, - modifier = - Modifier - .ifElse( - selectedIndex == index, - Modifier.focusRequester(focusRequester), - ), - ) - } - if (showMore) { - itemsIndexed(moreLibraries) { index, it -> - val adjustedIndex = (index + libraries.size + 1) + itemsIndexed(state.items) { index, it -> + if (it !is NavDrawerItem.Discover || state.discoverEnabled) { val interactionSource = remember { MutableInteractionSource() } NavItem( library = it, - selected = selectedIndex == adjustedIndex, - moreExpanded = showMore, + selected = selectedIndex == index, + moreExpanded = moreExpanded, drawerOpen = isOpen, - onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) }, - containerColor = - if (isOpen) { - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - } else { - Color.Unspecified - }, interactionSource = interactionSource, + onClick = { + viewModel.onClickDrawerItem(index, it) + }, modifier = Modifier .ifElse( - selectedIndex == adjustedIndex, + selectedIndex == index, Modifier.focusRequester(focusRequester), ), ) } } + 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 = moreExpanded, + drawerOpen = isOpen, + onClick = { + viewModel.onClickDrawerItem( + adjustedIndex, + it, + ) + }, + containerColor = + if (isOpen) { + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + } else { + Color.Unspecified + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + selectedIndex == adjustedIndex, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + } 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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 2b7d61bc..e8dc7d1d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index b3813ed5..e1a291e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -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, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt index 55fd5424..474c7932 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt @@ -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 + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 04ff620a..c6d7b622 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -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, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index ac6aaeed..b1500b60 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -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,10 +336,19 @@ fun PlaybackOverlay( }, interactionSource = interactionSource, modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(focusRequester), - ), + Modifier + .ifElse( + index == chapterIndex, + Modifier + .focusRequester(focusRequester) + .bringIntoViewRequester(bringIntoViewRequester), + ).ifElse( + index == 0, + 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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index f8b80020..0313a8c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -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), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 931ac551..4d44ff80 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -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.EMPTY) val currentPlayback = MutableStateFlow(null) val currentItemPlayback = MutableLiveData() - val currentSegment = EqualityMutableLiveData(null) - private val autoSkippedSegments = mutableSetOf() + val currentSegment = MutableStateFlow(null) val subtitleCues = MutableLiveData>(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() + private val outroShownSegments = mutableSetOf() /** * 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,20 +1002,27 @@ 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 ) { - Timber.d( - "Found media segment for %s: %s, %s", - currentSegment.itemId, - currentSegment.id, - currentSegment.type, - ) + if (currentSegment.id != + this@PlaybackViewModel + .currentSegment.value + ?.segment + ?.id + ) { + Timber.d( + "Found media segment for %s: %s, %s", + currentSegment.itemId, + 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, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt new file mode 100644 index 00000000..afdc93a1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -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, + ) { + items.map { (item, pinned) -> + NavDrawerPin(item.id, item.name(context), pinned, item) + } + } + } +} + +enum class MoveDirection { + UP, + DOWN, +} + +private fun List.move( + direction: MoveDirection, + index: Int, +): List = + 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, + 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, + 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>(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) { + 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") + } + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index dcbda2fc..b04293f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -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.None) } + var showQuickConnectDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { @@ -165,291 +168,310 @@ 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, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) - } - if (UpdateChecker.ACTIVE && - preferenceScreenOption == PreferenceScreenOption.BASIC && - preferences.autoCheckForUpdates && - updateAvailable + Text( + text = stringResource(screenTitle), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.fillMaxSize(), ) { - item { - val updateFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (focusedIndex.first == 0 && focusedIndex.second == 0) { - // Only re-focus if the user hasn't moved - updateFocusRequester.tryRequestFocus() - } - } - ClickPreference( - title = stringResource(R.string.install_update), - onClick = { - if (movementSounds) playOnClickSound(context) - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - }, - summary = release?.version?.toString(), - modifier = - Modifier - .focusRequester(updateFocusRequester) - .playSoundOnFocus(movementSounds), - ) - } - } - prefList.forEachIndexed { groupIndex, group -> - item { - Text( - text = stringResource(group.title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Start, - modifier = - Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 4.dp), - ) - } - val groupPreferences = - group.preferences + - group.conditionalPreferences - .filter { it.condition.invoke(preferences) } - .map { it.preferences } - .flatten() - groupPreferences.forEachIndexed { prefIndex, pref -> - pref as AppPreference + if (UpdateChecker.ACTIVE && + preferenceScreenOption == PreferenceScreenOption.BASIC && + preferences.autoCheckForUpdates && + updateAvailable + ) { item { - val interactionSource = remember { MutableInteractionSource() } - val focused = interactionSource.collectIsFocusedAsState().value - LaunchedEffect(focused) { - if (focused) { - focusedIndex = Pair(groupIndex, prefIndex) - if (movementSounds) playOnClickSound(context) - onFocus.invoke(groupIndex, prefIndex) + val updateFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (focusedIndex.first == 0 && focusedIndex.second == 0) { + // Only re-focus if the user hasn't moved + updateFocusRequester.tryRequestFocus() } } - when (pref) { - AppPreference.InstalledVersion -> { - var clickCount by remember { mutableIntStateOf(0) } - ClickPreference( - title = stringResource(R.string.installed_version), - onClick = { - if (movementSounds) playOnClickSound(context) - if (clickCount++ >= 2) { - clickCount = 0 - viewModel.navigationManager.navigateTo(Destination.Debug) - } - }, - summary = installedVersion.toString(), - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) - } - - AppPreference.Update -> { - ClickPreference( - title = - if (release != null && updateAvailable) { - stringResource(R.string.install_update) - } else if (!preferences.autoCheckForUpdates && release == null) { - stringResource(R.string.check_for_updates) - } else { - stringResource(R.string.no_update_available) - }, - onClick = { - if (movementSounds) playOnClickSound(context) - if (release != null && updateAvailable) { - release?.let { - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - } - } else { - updateVM.init(preferences.updateUrl) - } - }, - onLongClick = { - if (movementSounds) playOnClickSound(context) - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - }, - summary = - if (updateAvailable) { - release?.version?.toString() - } else { - null - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) - } - - AppPreference.ClearImageCache -> { - val summary = - remember(cacheUsage) { - cacheUsage.let { - val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT - val memoryUsedMB = - it.imageMemoryUsed / AppPreference.MEGA_BIT - val memoryMaxMB = - it.imageMemoryMax / AppPreference.MEGA_BIT - "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb" - } - } - ClickPreference( - title = stringResource(pref.title), - onClick = { - SingletonImageLoader.get(context).let { - it.memoryCache?.clear() - it.diskCache?.clear() - updateCache = true - } - }, - modifier = Modifier, - summary = summary, - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.UserPinnedNavDrawerItems -> { - val selectedItems = - navDrawerPins.keys.mapNotNull { - if (navDrawerPins[it] ?: false) it else null - } - MultiChoicePreference( - title = stringResource(pref.title), - summary = pref.summary(context, null), - possibleValues = navDrawerPins.keys, - selectedValues = selectedItems.toSet(), - onValueChange = { newSelectedItems -> - viewModel.updatePins(newSelectedItems) - }, - ) { - Text(it.name(context)) + ClickPreference( + title = stringResource(R.string.install_update), + onClick = { + if (movementSounds) playOnClickSound(context) + viewModel.navigationManager.navigateTo(Destination.UpdateApp) + }, + summary = release?.version?.toString(), + modifier = + Modifier + .focusRequester(updateFocusRequester) + .playSoundOnFocus(movementSounds), + ) + } + } + prefList.forEachIndexed { groupIndex, group -> + item { + Text( + text = stringResource(group.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) + } + val groupPreferences = + group.preferences + + group.conditionalPreferences + .filter { it.condition.invoke(preferences) } + .map { it.preferences } + .flatten() + groupPreferences.forEachIndexed { prefIndex, pref -> + pref as AppPreference + item { + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) + if (movementSounds) playOnClickSound(context) + onFocus.invoke(groupIndex, prefIndex) } } - - AppPreference.SendAppLogs -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - viewModel.sendAppLogs() - }, - modifier = Modifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - SubtitleSettings.Reset -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - viewModel.resetSubtitleSettings() - }, - modifier = Modifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.RequireProfilePin -> { - SwitchPreference( - title = stringResource(pref.title), - value = currentUser?.pin.isNotNullOrBlank(), - onClick = { - showPinFlow = true - }, - summaryOn = stringResource(R.string.enabled), - summaryOff = null, - modifier = Modifier, - ) - } - - AppPreference.SeerrIntegration -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - if (seerrIntegrationEnabled) { - seerrDialogMode = SeerrDialogMode.Remove - } else { - seerrVm.resetStatus() - seerrDialogMode = SeerrDialogMode.Add - } - }, - modifier = Modifier, - summary = - if (seerrIntegrationEnabled) { - stringResource(R.string.enabled) - } else { - null - }, - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - else -> { - val value = pref.getter.invoke(preferences) - ComposablePreference( - preference = pref, - value = value, - onNavigate = viewModel.navigationManager::navigateTo, - onValueChange = { newValue -> - val validation = pref.validate(newValue) - when (validation) { - is PreferenceValidation.Invalid -> { - // TODO? - Toast - .makeText( - context, - validation.message, - Toast.LENGTH_SHORT, - ).show() + when (pref) { + AppPreference.InstalledVersion -> { + var clickCount by remember { mutableIntStateOf(0) } + ClickPreference( + title = stringResource(R.string.installed_version), + onClick = { + if (movementSounds) playOnClickSound(context) + if (clickCount++ >= 2) { + clickCount = 0 + viewModel.navigationManager.navigateTo(Destination.Debug) } + }, + summary = installedVersion.toString(), + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } - PreferenceValidation.Valid -> { - scope.launch(ExceptionHandler()) { - preferences = - viewModel.preferenceDataStore.updateData { prefs -> - pref.setter(prefs, newValue) - } + AppPreference.Update -> { + ClickPreference( + title = + if (release != null && updateAvailable) { + stringResource(R.string.install_update) + } else if (!preferences.autoCheckForUpdates && release == null) { + stringResource(R.string.check_for_updates) + } else { + stringResource(R.string.no_update_available) + }, + onClick = { + if (movementSounds) playOnClickSound(context) + if (release != null && updateAvailable) { + release?.let { + viewModel.navigationManager.navigateTo(Destination.UpdateApp) + } + } else { + updateVM.init(preferences.updateUrl) + } + }, + onLongClick = { + if (movementSounds) playOnClickSound(context) + viewModel.navigationManager.navigateTo(Destination.UpdateApp) + }, + summary = + if (updateAvailable) { + release?.version?.toString() + } else { + null + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } + + AppPreference.ClearImageCache -> { + val summary = + remember(cacheUsage) { + cacheUsage.let { + val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT + val memoryUsedMB = + it.imageMemoryUsed / AppPreference.MEGA_BIT + val memoryMaxMB = + it.imageMemoryMax / AppPreference.MEGA_BIT + "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb" + } + } + ClickPreference( + title = stringResource(pref.title), + onClick = { + SingletonImageLoader.get(context).let { + it.memoryCache?.clear() + it.diskCache?.clear() + updateCache = true + } + }, + modifier = Modifier, + summary = summary, + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + AppPreference.UserPinnedNavDrawerItems -> { + NavDrawerPreference( + title = stringResource(pref.title), + summary = pref.summary(context, null), + modifier = Modifier, + interactionSource = interactionSource, + ) + } + + AppPreference.SendAppLogs -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.sendAppLogs() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + SubtitleSettings.Reset -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.resetSubtitleSettings() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + AppPreference.RequireProfilePin -> { + SwitchPreference( + title = stringResource(pref.title), + value = currentUser?.pin.isNotNullOrBlank(), + onClick = { + showPinFlow = true + }, + summaryOn = stringResource(R.string.enabled), + summaryOff = null, + modifier = Modifier, + ) + } + + AppPreference.SeerrIntegration -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + 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 = + 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( + preference = pref, + value = value, + onNavigate = viewModel.navigationManager::navigateTo, + onValueChange = { newValue -> + val validation = pref.validate(newValue) + when (validation) { + is PreferenceValidation.Invalid -> { + // TODO? + Toast + .makeText( + context, + validation.message, + Toast.LENGTH_SHORT, + ).show() + } + + PreferenceValidation.Valid -> { + scope.launch(ExceptionHandler()) { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter(prefs, newValue) + } + } } } - } - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } } } } @@ -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 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 9973bbec..bec82e58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -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 - val navDrawerPins = MutableLiveData>(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.Pending) + val quickConnectStatus: StateFlow = _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) { - viewModelScope.launchIO(ExceptionHandler(true)) { - serverRepository.currentUser.value?.let { user -> - val disabledItems = - mutableListOf().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.updateData { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt new file mode 100644 index 00000000..dfad49b5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt @@ -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), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index d2b48c35..81e06fb8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index 3601fdd7..9566b88b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -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", - items = - listOf( - DialogItem( - text = "API Key", - onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, - ), - DialogItem( - text = "Jellyfin user", - onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, - ), - DialogItem( - text = "Local user", - onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, - ), + DialogParams( + fromLongClick = false, + title = stringResource(R.string.seerr_login), + items = + listOf( + DialogItem( + text = stringResource(R.string.api_key), + onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, ), - ) - } + DialogItem( + text = stringResource(R.string.seerr_jellyfin_user), + onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, + ), + DialogItem( + text = stringResource(R.string.seerr_local_user), + onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, + ), + ), + ) + DialogPopup( params = params, onDismissRequest = onDismissRequest, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index a513715d..1c121ef6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -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 -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt new file mode 100644 index 00000000..73f22de6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt @@ -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 + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index 44e4fd2a..0c577571 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index c35e9512..5da98fec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -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, + val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), ) : HomeRowLoadingState data class Error( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index b98ececd..6272af5a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt index 0a936de9..a4cd60c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt @@ -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 } diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 1a328c33..f0e3d0be 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -475,4 +475,40 @@ Trvání prezentace Přehrávat videa během prezentace Odesílat protokol informací o médiích serveru + + %s den + %s dny + %s dní + %s dní + + Bez omezení + Max. počet dnů v Dalších v pořadí + Rychlé připojení + Autorizovat jiné zařízení k přihlášení k vašemu účtu + Zadejte kód Rychlého připojení + Kód musí mít 6 číslic + Zařízení úspěšně autorizováno + Přidat řádek + Žánry v %1$s + Nedávno vydáno v %1$s + Výška + Použít na všechny řádky + Přizpůsobit domovskou stránku + Řádky na domovské stránce + Načíst ze serveru + Uložit na server + Načíst z webového klienta + Použít obrázek seriálu + Přidat řádek pro %1$s + Přepsat nastavení na serveru? + Přepsat místní nastavení? + Pro epizody + Návrhy na %1$s + Zvětšit velikost všech karet + Zmenšit velikost všech karet + Použít obrázky miniatur + Nastavení uložena + Zobrazit předvolby + Vestavěné předvolby pro rychlou úpravu všech řádků + Vyberte řádky a obrázky na domovské stránce diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml new file mode 100644 index 00000000..43da96b0 --- /dev/null +++ b/app/src/main/res/values-da/strings.xml @@ -0,0 +1,307 @@ + + + Om + Aktive optagelser + Favorit + Tilføj server + Tilføj bruger + Lyd + Fødested + Bitrate + Født + Annullér optagelse + Annullér serieoptagelse + Annullér + Kapitler + Vælg %1$s + Ryd billedcache + Samling + Samlinger + Brugerbedømmelse + Der opstod en fejl! Tryk på knappen for at sende logfiler til din server. + Bekræft + Fortsæt med at se + Kritikerbedømmelse + %.2f sekunder + Standard + Slet + Død + Instrueret af %1$s + Instruktør + Deaktiveret + Opdagede servere + + Downloader… + Aktiveret + Slutter %1$s + Indtast server-IP eller URL + Indtast serveradresse + Episoder + Fejl ved indlæsning af samling %1$s + Ekstern + Favoritter + Størrelse + Tvunget + Genrer + Gå til serien + Gå til + Skjul afspilningsknapper + Skjul fejlfindingsoplysninger + Skjul + Hjem + Umiddelbar + Intro + #ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ + Bibliotek + Licensoplysninger + Live-tv + Indlæser… + Markér hele serien som afspillet? + Markér hele serien som ikke afspillet? + Markér som uset + Markér som set + Maks. bitrate + Mere som dette + Mere + + Film + + + Navn + Næste + Ingen data + Ingen resultater + Ingen servere fundet + Ingen planlagte optagelser + Ingen opdatering tilgængelig + Ingen + Kun tvungne undertekster + Outro + Filsti + + Personer + + + Antal afspilninger + Afspil herfra + Afspil + Vis debugoplysninger for afspilning + Afspilningshastighed + Afspilning + Playlist + Playlists + Forhåndsvisning + Profilindstillinger + + Opsummering + Nyligt tilføjet i %1$s + Nyligt tilføjet + Nyligt optaget + Nyligt udkommet + Anbefalet + Optag program + Optag serie + Fjern fra favoritter + Genstart + Fortsæt + Gem + + Søg + Søger… + Stemmesøgning + Starter + Tal for at søge + Arbejder + Tryk tilbage for at afbryde + Fejl ved lydoptagelse + Fejl ved stemmegenkendelse + Mikrofontilladelse kræves + Netværksfejl + Netværkstimeout + Ingen tale genkendt + Stemmegenkendelse optaget + Serverfejl + Ingen tale registreret + Ukendt fejl + Kunne ikke starte stemmegenkendelse + Tidsgrænsen for stemmegenkendelse er overskredet + Prøv igen + Vælg server + Vælg bruger + Vis fejlfindingsoplysninger + Vis næste + Vis + Bland + Spring over + Dato tilføjet + Dato episode tilføjet + Dato afspillet + Udgivelsesdato + Navn + Tilfældig + Studier + Indsend + Downloaden tager lang tid, du skal muligvis genstarte afspilningen + Undertekst + Undertekster + Forslag + Skift servere + Skift + Bedst bedømte usete + Trailer + + Trailere + + + DVR-plan + TV-guide + Sæson + Sæsoner + + TV-serier + + + Grænseflade + Ukendt + Opdateringer + Version + Videoskalering + Video + Videoer + Se direkte + %1$d år gammel + Afspil med transkodning + Medieinformation + Vis ur + Udsendelsesrækkefølge + Opret ny playliste + Føj til playliste + Pause med ét klik + Tryk på midten af D-Pad\'en for at pause/afspille + Kursiv skrift + Skrifttype + Baggrund + Succes + Aldersgrænse + Spilletid + Ekstra + + Andet + + + + Bag kulisserne + + + + Temasange + + + + Temavideoer + + + + Klip + + + + Slettede scener + + + + Interview + + + + Scener + + + + Prøver + + + + Specialindslag + + + + Kortfilm + + + + %s download + %s downloads + + + %d time + %d timer + + + %s dag + %s dage + + + %d element + %d elementer + + + %d sekund + %d sekunder + + Enheden understøtter AC3/Dolby Digital + Avancerede indstillinger + Avanceret brugergrænseflade + Tema + Søg automatisk efter opdateringer + Forsinkelse før næste afspilning + Afspil næste automatisk + Søg efter opdateringer + Gælder kun for tv-serier + + Direkte afspilning af ASS-undertekster + Direkte afspilning af PGS-undertekster + Mix altid ned til stereo + Brug FFmpeg-dekodermodulet + Standard skalering + Installer opdatering + Installeret version + Maks antal elementer per række på startsiden + Vælg de standardelementer, der skal vises, andre vil blive skjult + Tilpas elementer i navigationsmenuen + Klik for at skifte side + Skift sider i navigationsmenuen ved fokus + Inaktivitetsbeskyttelse + Afspil temamusik + Tilsidesættelser af afspilning + Husk valgte faner + Tillad gensening i \"Næste\" + Trinlængde for søgelinje + Nyttig til fejlfinding + Send app-logfiler til den aktuelle server + Vil forsøge at sende til den sidst forbundne server + Send nedbrudsrapporter + Indstillinger + Spring tilbage, når afspilningen genoptages + Spring tilbage + Adfærd ved at springe reklamer over + Spring fremad + Adfærd ved at springe intro over + Adfærd ved at springe outro over + Adfærd ved at springe forhåndsvisninger over + Adfærd ved at springe opsummeringer over + Opdatering tilgængelig + URL brugt til at søge efter appopdateringer + URL til opdatering + Tekststørrelse + Tekstfarve + Tekstgennemsigtighed + Fed skrift + Kantstil + Kantfarve + Baggrundsopacitet + Baggrundsstil + Undertekststil + Baggrundsfarve + Nulstil + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index eddd471d..013f5ccd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,7 +19,7 @@ Bestätigen Weiterschauen Kritiken - %.1f Sekunden + %.2f Sekunden Standard Löschen Gestorben @@ -321,7 +321,7 @@ Nein Titel anzeigen Wiederholen - Generell + Allgemein Keine Trailer Trailer abspielen Lokal @@ -417,4 +417,19 @@ Diashow anhalten Helligkeit Kontrast + Verkleinern + Vergrößern + Gerät erfolgreich authorisiert + Sättigung + Farbton + + %s Tag + %s Tage + + Der Code muss aus 6 Ziffern bestehen + HDR-Untertitelstil + Dauer der Diashow + Videos während der Diashow abspielen + Maximale Tage in \"Als nächstes\" + Quick Connect diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 31445b5a..831c6036 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -15,7 +15,7 @@ Comercial Confirmar Continuar viendo - %.1f segundos + %.2f segundos Eliminar Fallecido Activado @@ -181,7 +181,7 @@ Clip Clips - + Clips Escena eliminada @@ -422,4 +422,61 @@ Solicitar en 4K Decodificación por software AV1 MPV es ahora el reproductor por defecto para HDR.\nPuedes cambiar esto en los ajustes. + + %s día + %s días + %s días + + Conexión Rápida + Autoriza a otro dispositivo para acceder a tu cuenta + Introduce el código de Conexión Rápida + El código debe tener 6 dígitos + Dispositivo autorizado con éxito + Estilo de subtítulos HDR + Opacidad de los subtítulos + Brillo + Contraste + Saturación + Tono de color + Rojo + Verde + Azul + Desenfoque + Guardar en el álbum + Reproducir presentación + Detener presentación + Presentación inicial + No hay más imágenes + Girar a la izquierda + Girar a la derecha + Acercar + Alejar + Duración de la presentación + Reproducir vídeos durante la presentación + Enviar registro de medios al servidor + Sin límite + Límite de días en \"Siguiente\" + Añadir fila + Géneros de %1$s + Altura + Aplicar a todas las filas + Personalizar página de inicio + Filas de inicio + Cargar desde el servidor + Guardar en el servidor + Cargar del cliente web + Usar imágenes de la serie + Agregar fila para %1$s + ¿Sobrescribir ajustes en el servidor? + ¿Sobrescribir ajustes locales? + Para episodios + Sugerencias para %1$s + Aumentar tamaño de todas las tarjetas + Reducir tamaño de todas las tarjetas + Usar miniaturas + Ajustes guardados + Preajustes de visualización + Preajustes integrados para estilizar todas las filas + Elegir filas e imágenes en la página de inicio + Lanzado recientemente en %1$s diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 482554a0..d4d5d17f 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -427,4 +427,39 @@ Suumi välja Slaidiesitluse kestus Slaidiesitluse ajal esita videoid + + %s päev + %s päeva + + Kiirühendus + Luba muul seadmel logida sinu kasutajakontoga sisse + Sisesta kiirühenduse kood + Kood peab olema kuuenumbriline + Seadme autentimine õnnestus + Salvesta meediumi infologi serverisse + Piirangut pole + Järgmisena esitamisel sisu päevade ülempiir + Lisa rida + Žanrid: %1$s + Hiljuti avaldatud: %1$s + Kõrgus + Rakenda kõikidele ridadele + Kohanda kodulehte + Kodulehe/avalehe read + Laadi serverist + Salvesta serverisse + Laadi veebikliendist + Kasuta sarja pilti + Lisa rida: %1$s + Kas kirjutad serveris leiduvad seadistused üle? + Kas kirjutad kohalikud seadistused üle? + Episoodide jaoks + Soovitused: %1$s + Suurenda suurust kõikide kaartide jaoks + Vähenda suurust kõikide kaartide jaoks + Kasuta pisipilte + Seadistused on salvesatatud + Kuvamise eelseadistused + Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks + Vali kodulehe/avalehe read ja pildid diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml new file mode 100644 index 00000000..045e125f --- /dev/null +++ b/app/src/main/res/values-fi/strings.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c131a379..ae405a53 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -24,7 +24,7 @@ Note de la communauté Une erreur est survenue ! Appuyez sur le bouton pour envoyer les logs à votre serveur. Note des critiques - %.1f secondes + %.2f secondes Décédé(e) Désactivé Téléchargement en cours… @@ -443,4 +443,40 @@ Dézoomer Durée du diaporama Diffuser des vidéos pendant le diaporama + + %s jour + %s jours + %s jours + + Connexion rapide + Autoriser un autre appareil à se connecter à votre compte + Entrez le code de connexion rapide + Le code doit comporter 6 chiffres + Appareil autorisé avec succès + Envoyer le journal des informations multimédias au serveur + Aucune limite + Nombre maximal de jours dans Prochainement + Ajouter une ligne + Genres en %1$s + Récemment publié en %1$s + Hauteur + S\'applique à toutes les lignes + Personnaliser la page d\'accueil + Lignes d\'accueil + Charger depuis le serveur + Enregistrer sur le serveur + Charger à partir du client Web + Utiliser l\'image de la série + Ajouter une ligne pour %1$s + Écraser les paramètres sur le serveur ? + Écraser les paramètres locaux ? + Pour les épisodes + Suggestions pour %1$s + Augmenter la taille pour toutes les cartes + Diminuer la taille pour toutes les cartes + Utilisez des images de miniatures + Paramètres enregistrés + Afficher les préréglages + Préréglages intégrés pour styliser rapidement toutes les lignes + Choisissez des lignes et des images sur la page d\'accueil diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 03fd5a93..91708cf7 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -412,4 +412,37 @@ Durasi tayangan slide Putar video selama tayangan slide Kirim log info media ke server + + %s hari + + Koneksi Cepat + Izinkan perangkat lain untuk masuk ke akun kamu + Masukkan kode Koneksi Cepat + Kode harus terdiri dari 6 digit + Perangkat berhasil diizinkan + Tanpa batasan + Batas hari untuk Berikutnya + Tambah baris + Genre di %1$s + Baru saja dirilis di %1$s + Tinggi + Terapkan ke semua baris + Kustomisasi beranda + Baris beranda + Muat dari server + Simpan ke server + Muat dari klien web + Gunakan gambar seri + Tambah baris untuk %1$s + Timpa pengaturan di server? + Timpa pengaturan lokal? + Untuk episode + Rekomendasi untuk %1$s + Perbesar ukuran semua kartu + Perkecil ukuran semua kartu + Gunakan gambar thumbnail + Pengaturan disimpan + Preset tampilan + Preset bawaan untuk kustomisasi cepat semua baris + Pilih baris dan gambar di halaman beranda diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e5aa51b4..0aadb5bd 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -444,4 +444,39 @@ Durata slideshow Riproduci video durante lo slideshow Invia log informazioni media al server + + %s giorno + %s giorni + %s giorni + + Nessun limite + Giorni massimi in Prossimo + Connessione rapida + Autorizza un altro dispositivo per accedere al tuo account + Inserisci il codice di Connessione rapida + Il codice deve essere di 6 cifre + Dispositivo autorizzato con successo + Aggiungi riga + Generi in %1$s + Recentemente rilasciato in %1$s + Altezza + Applica a tutte le righe + Carica dal server + Aggiungi riga per %1$s + Sovrascrivere le impostazioni sul server? + Sovrascrivere le impostazioni locali? + Suggerimenti per %1$s + Aumenta dimensione di tutte le card + Riduci dimensione di tutte le card + Usa immagini thumbnail + Impostazioni salvate + Mostra preset + Preset integrati per stilizzare rapidamente tutte le righe + Righe home + Per gli episodi + Personalizza home page + Salva sul server + Carica dal client web + Usa immagine delle serie + Scegli righe e immagini nella home page diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 00000000..c7889af3 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,34 @@ + + + Sobre + Gravações ativas + Favoritar + Adicionar Servidor + Adicionar Usuário + Áudio + Local de nascimento + Taxa de bits + Nascido + Cancelar gravação + Cancelar gravação da série + Cancelar + Capítulos + Escolha %1$s + Limpar cache de imagens + Coleção + Coleções + Comerciais + Avaliação da comunidade + Ocorreu um erro! Aperte o botão para enviar os logs para seu servidor. + Confirmar + Continue assistindo + Avaliação dos críticos + %.2f segundos + Padrão + Remover + Falecido + Dirigido por %1$s + Diretor + Desabilitado + Servidores encontrados + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c2ee4aec..1fb5fbb5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,6 +1,6 @@ - O Wholphin + Sobre Favorito Adicionar Servidor Adicionar Utilizador @@ -10,8 +10,8 @@ Capítulos Escolher %1$s Eliminar cache de imagens - Colecção - Colecções + Coleção + Coleções Confirmar %.2f segundos Padrão @@ -27,7 +27,7 @@ Tamanho Forçado Ir para - Gravações Activas + Gravações Ativas Local de Nascimento Bitrate Nascido @@ -451,4 +451,32 @@ Enviar registo de informações de mídia para o servidor Sem limite Dias máximos em \'A Seguir\' + Ligação Rápida + Autorize outro dispositivo a iniciar sessão na sua conta + Insira o código de Ligação Rápida + Código deve ter 6 dígitos + Dispositivo autorizado com sucesso + Adicionar linha + Géneros em %1$s + Lançado recentemente em %1$s + Altura + Aplicar a todas as linhas + Personalizar página inicial + Linhas da página inicial + Carregar do servidor + Gravar para o servidor + Carregar a partir do cliente web + Usar imagem da série + Adicionar linha para %1$s + Substituir as definições no servidor? + Substituir as definições locais? + Por episódios + Sugestões para %1$s + Aumentar o tamanho de todos os cartões + Usar imagens de prévia + Configurações salvas + Mostrar pré definições + Predefinições embarcadas para definir todas as linhas rapidamente + Escolha as linhas e imagens na página inicial + Diminuir o tamanho de todos os cartões diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 745da44f..117d6e2f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -66,7 +66,7 @@ Diğer Filmler - + Filmler Ad Sıradaki bölümler @@ -81,7 +81,7 @@ Yol Kişiler - + Kişiler Oynatma Sayısı Buradan oynat @@ -152,7 +152,7 @@ Fragman Fragmanlar - + Fragmanlar Kayıt Takvimi Rehber @@ -160,7 +160,7 @@ Sezonlar Diziler - + Diziler Arayüz Bilinmiyor @@ -436,4 +436,38 @@ Uzaklaştır Slayt Gösterisi Süresi Slayt gösterisi sırasında videoları oynat + + %s gün + %s günler + + Medya bilgisini sunucuya gönder + Sınırsız + Sıradaki bölümlerde kalacağı gün sayısı + Hızlı Bağlan + Başka bir cihazın giriş yapmasına izin ver + Hızlı Bağlan kodunu girin + Kod 6 haneli olmalıdır + Cihaz başarıyla yetkilendirildi + Satır ekle + %1$s içindeki türler + %1$s kütüphanesinde son çıkanlar + Yükseklik + Tüm satırlara uygula + Ana ekranı özelleştir + Ana ekran satırları + Sunucudan yükle + Sunucuya kaydet + Web istemcisinden yükle + Dizi görselini kullan + %1$s için satır ekle + Sunucudaki ayarların üzerine yazılsın mı? + Yerel ayarların üzerine yazılsın mı? + Bölümler için + %1$s için tavsiyeler + Tüm kartların boyutunu artır + Tüm kartların boyutunu azalt + Küçük resimleri kullan + Ayarlar kaydedildi + Görüntü ön ayarları + Tüm satırları hızlıca stilize etmek için hazır ayarlar diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b08bfeb5..49bab214 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -117,7 +117,7 @@ 下载时间过长,可能需要重新开始播放 字幕 字幕 - 建议 + 推荐 切换服务器 切换 高分未看 @@ -215,7 +215,7 @@ 默认内容比例 安装更新 安装版本 - 首页每行最大项目数 + 首页行最大项目数 选择默认显示的项目,其他项目将隐藏 自定义抽屉式导航栏项目 点击切换页面 @@ -417,4 +417,32 @@ 无限制 即将播放中的最大天数 + 快速连接 + 授权其他设备登录您的账号 + 输入快速连接代码 + 代码必须为六位数字 + 设备授权成功 + 添加行 + %1$s 中的类型 + %1$s 最近发行 + 高度 + 应用到所有行 + 自定义首页 + 首页行 + 从服务器加载 + 保存到服务器 + 从网络客户端加载 + 使用剧集图片 + 为 %1$s 添加行 + 覆盖服务器上的设置? + 覆盖本地设置? + 针对剧集 + %1$s 推荐内容 + 增大所有卡片的尺寸 + 减小所有卡片的尺寸 + 使用缩略图 + 设置已保存 + 显示预设 + 可快速设置所有行样式的内置预设 + 选择首页上的行和图片 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 330f5dd0..1cae12e0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -411,4 +411,38 @@ 幻燈片播放時播放影片 圖形字幕不透明度 亮度 + + %s 天 + + 將媒體訊息日誌傳送到伺服器 + 無限制 + 接下來播放的保留天數 + 快速連線 + 授權其他裝置登入您的帳號 + 輸入快速連線驗證碼 + 驗證碼必須為6位數 + 裝置授權成功 + %1$s 類型 + 最近發行於 %1$s + 推薦的 %1$s + 放大所有海報尺寸 + 縮小所有海報尺寸 + 使用橫向縮圖 + 新增一列 + 套用到所有列 + 首頁各列 + 為 %1$s 新增列項目 + 快速設定所有項目為內建預設樣式 + 選擇首頁要顯示的列項目及圖片樣式 + 高度 + 自訂首頁 + 從伺服器載入 + 保存到伺服器 + 從網頁版載入 + 使用劇集圖片 + 覆蓋伺服器上的設定? + 覆蓋本地設定? + 設定已儲存 + 顯示預設樣式 + 針對單集 diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 9fcb0779..d3c3537a 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -51,5 +51,7 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef76342b..8b2e8d12 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -188,6 +188,7 @@ Samples Featurettes Shorts + Favorite %s Trailers @@ -458,6 +459,11 @@ Seerr integration Remove Seerr Server Seerr server added + Quick Connect + Authorize another device to log in to your account + Enter Quick Connect code + Code must be 6 digits + Device authorized successfully Password Username URL @@ -501,104 +507,184 @@ Go to album Add to queue + Add row + Genres in %1$s + Recently released in %1$s + Height + Apply to all rows + Customize home page + Home rows + Load from server user profile + Save to server user profile + Load from web client + Use series image + Add row for %1$s + Overwrite settings on server? + Overwrite local settings? + For episodes + Suggestions for %1$s + Increase size for all cards + Decrease size for all cards + Use thumb images + Settings saved + Display presets + Built-in presets to quickly style all rows + Choose rows and images on the home page + Fit + Crop + Fill + Fill width + Fill height + Wholphin Default + Wholphin Compact + Series Thumb images + Episode Thumbnail images + + Lowest + Low + Medium + High + Full - Disabled - Lowest - Low - Medium - High - Full + @string/disabled + @string/volume_lowest + @string/volume_low + @string/volume_medium + @string/volume_high + @string/volume_full + Purple + Orange + Bold Blue + Black - Purple - Blue - Green - Orange - Bold Blue - Black + @string/purple + @string/blue + @string/green + @string/orange + @string/bold_blue + @string/black + Ignore + Skip automatically + Ask to skip - Ignore - Skip automatically - Ask to skip + @string/skip_ignore + @string/skip_automatically + @string/skip_ask - Fit - None - Crop - Fill - Fill Width - Fill Height + @string/content_scale_fit + @string/none + @string/content_scale_crop + @string/content_scale_fill + @string/content_scale_fill_width + @string/content_scale_fill_height + Only use FFmpeg if no built-in decoder exists + Prefer to use FFmpeg over built-in decoders + Never use FFmpeg decoders - Only use FFmpeg if no built-in decoder exists - Prefer to use FFmpeg over built-in decoders - Never use FFmpeg decoders + @string/ffmpeg_fallback + @string/ffmpeg_prefer + @string/ffmpeg_never + At the end of playback + During end credits/outro - At the end of playback - During end credits/outro + @string/next_up_playback_end + @string/next_up_outro + White + Light gray + Dark gray + Yellow + Cyan + Magenta - White - Black - Light Gray - Dark Gray - Red - Yellow - Green - Cyan - Blue - Magenta + @string/white + @string/black + @string/light_gray + @string/dark_gray + @string/red + @string/yellow + @string/green + @string/cyan + @string/blue + @string/magenta + Outline + Shadow - None - Outline - Shadow + @string/none + @string/subtitle_edge_outline + @string/subtitle_edge_shadow + Wrap + Boxed - None - Wrap - Boxed + @string/none + @string/background_style_wrap + @string/background_style_boxed + ExoPlayer + MPV + Prefer MPV - ExoPlayer - MPV - Prefer MPV + @string/exoplayer + @string/mpv + @string/prefer_mpv + Use ExoPlayer for HDR playback - - - Use ExoPlayer for HDR playback + + + @string/player_backend_options_subtitles_prefer_mpv + Poster (2:3) + 16:9 + 4:3 + Square (1:1) - Poster (2:3) - 16:9 - 4:3 - Square (1:1) + @string/aspect_ratios_poster + @string/aspect_ratios_16_9 + @string/aspect_ratios_4_3 + @string/aspect_ratios_square + Primary + Thumb - Primary - Thumb + @string/image_type_primary + @string/image_type_thumb + Image with dynamic color + Image only + + API Key + Login to Seerr server + Jellyfin user + Local user + + No remote subtitles were found + Present - Image with dynamic color - Image only - None + @string/backdrop_style_dynamic + @string/backdrop_style_image + @string/none diff --git a/app/src/patches/play_store.patch b/app/src/patches/play_store.patch new file mode 100644 index 00000000..ef5fb96d --- /dev/null +++ b/app/src/patches/play_store.patch @@ -0,0 +1,40 @@ +commit f82fb1a2d8ff9917a7bdb0bc7101a0474359ccdc +Author: Damontecres +Date: Sat Nov 22 13:00:55 2025 -0500 + + Setup for play store + +diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml +index 6d84299..12576af 100644 +--- a/app/src/main/AndroidManifest.xml ++++ b/app/src/main/AndroidManifest.xml +@@ -4,7 +4,6 @@ + + + +- + +@@ -17,7 +16,7 @@ + android:required="false" /> + ++ android:required="true" /> + +diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +index c7ac435..fa42fe1 100644 +--- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt ++++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +@@ -62,7 +62,7 @@ class UpdateChecker + + private val NOTE_REGEX = Regex("") + +- val ACTIVE = true ++ val ACTIVE = false + } + + suspend fun maybeShowUpdateToast( diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt index e1663543..f8fc8445 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -150,6 +151,44 @@ class SuggestionServiceTest { assertEquals(SuggestionsResource.Empty, result) } + @Test + fun getSuggestionsFlow_returnsEmpty_whenCachedIdsEmpty_evenIfWorkIsEnqueued() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList()) + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Empty, result) + verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } + } + + @Test + fun getSuggestionsFlow_returnsLoading_whenCacheMissing_andWorkIsEnqueued() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Loading, result) + verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } + } + @Test fun passes_correct_arguments_to_cache() = runTest { diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt new file mode 100644 index 00000000..84aa72b6 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -0,0 +1,153 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.PrefContentScale +import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.components.ViewOptionImageType +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import io.mockk.mockk +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.junit.Assert +import org.junit.Test +import kotlin.reflect.KClass + +class TestHomeRowSamples { + companion object { + val SAMPLES = + listOf( + HomeRowConfig.RecentlyAdded( + parentId = UUID.randomUUID(), + viewOptions = + HomeRowViewOptions( + heightDp = 100, + spacing = 8, + contentScale = PrefContentScale.CROP, + aspectRatio = AspectRatio.FOUR_THREE, + imageType = ViewOptionImageType.THUMB, + showTitles = false, + useSeries = false, + ), + ), + HomeRowConfig.RecentlyReleased( + parentId = UUID.randomUUID(), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.Genres( + parentId = UUID.randomUUID(), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.ContinueWatching( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.NextUp( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.ContinueWatchingCombined( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.ByParent( + parentId = UUID.randomUUID(), + recursive = true, + sort = SortAndDirection(ItemSortBy.CRITIC_RATING, SortOrder.ASCENDING), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.GetItems( + name = "Episodes by date created", + getItems = + GetItemsRequest( + parentId = UUID.randomUUID(), + recursive = true, + isFavorite = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + ), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.Favorite(kind = BaseItemKind.SERIES), + HomeRowConfig.Recordings(), + HomeRowConfig.TvPrograms(), + HomeRowConfig.TvChannels(), + HomeRowConfig.Suggestions(parentId = UUID.randomUUID()), + ) + } + + @Test + fun `Check all types have a sample`() { + // This ensures there is a sample for each possible HomeRowConfig type + val foundTypes = mutableSetOf>() + SAMPLES.forEach { + when (it) { + is HomeRowConfig.ContinueWatching -> foundTypes.add(it::class) + is HomeRowConfig.ContinueWatchingCombined -> foundTypes.add(it::class) + is HomeRowConfig.Genres -> foundTypes.add(it::class) + is HomeRowConfig.NextUp -> foundTypes.add(it::class) + is HomeRowConfig.RecentlyAdded -> foundTypes.add(it::class) + is HomeRowConfig.RecentlyReleased -> foundTypes.add(it::class) + is HomeRowConfig.ByParent -> foundTypes.add(it::class) + is HomeRowConfig.GetItems -> foundTypes.add(it::class) + is HomeRowConfig.Favorite -> foundTypes.add(it::class) + is HomeRowConfig.Recordings -> foundTypes.add(it::class) + is HomeRowConfig.TvPrograms -> foundTypes.add(it::class) + is HomeRowConfig.Suggestions -> foundTypes.add(it::class) + is HomeRowConfig.TvChannels -> foundTypes.add(it::class) + } + } + Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size) + } + + @Test + fun `Print sample JSON`() { + // This just prints out the JSON of the samples so developers can review + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + } + val string = json.encodeToString(SAMPLES) + println(string) + json.decodeFromString>(string) + } + + @Test + fun `Parse list of rows with an unknown type`() { + val service = + HomeSettingsService( + context = mockk(), + api = mockk(), + serverRepository = mockk(), + userPreferencesService = mockk(), + navDrawerService = mockk(), + latestNextUpService = mockk(), + imageUrlService = mockk(), + suggestionService = mockk(), + ) + + val str = """{ + "type": "HomePageSettings", + "version": 1, + "rows": [ + { + "type": "RecentlyAdded", + "parentId": "1dd1c2fd-2e1b-48e4-ba94-17a2350fe9cf" + }, + { + "type": "Does not exist", + "viewOptions": {} + } + ] + }""" + + val jsonElement = service.jsonParser.parseToJsonElement(str) + val settings = service.decode(jsonElement) + Assert.assertEquals(1, settings.rows.size) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index f2004532..7e765b97 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.protobuf) apply false alias(libs.plugins.ksp) apply false diff --git a/gradle.properties b/gradle.properties index 132244e5..23480cc7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,3 +21,13 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=false +android.sdk.defaultTargetSdkToCompileSdkIfUnset=true +android.enableAppCompileTimeRClass=true +android.usesSdkInManifest.disallowed=true +android.uniquePackageNames=true +android.dependency.useConstraints=false +android.r8.strictFullModeForKeepRules=true +android.r8.optimizedResourceShrinking=true +android.builtInKotlin=true +android.newDsl=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 92c7835c..2c2234f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,18 @@ [versions] aboutLibraries = "13.2.1" acra = "5.13.1" -agp = "8.13.2" +agp = "9.0.1" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" -hiltCompiler = "1.3.0" hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" kache = "2.1.1" -kotlin = "2.3.0" -kotlinxCoroutinesCore = "1.10.2" -ksp = "2.3.5" +kotlin = "2.3.10" +ksp = "2.3.6" coreKtx = "1.17.0" appcompat = "1.7.1" -composeBom = "2026.01.01" +composeBom = "2026.02.00" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.39.2" @@ -25,18 +23,18 @@ timber = "5.0.1" tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.3" +activityCompose = "1.12.4" androidx-media3 = "1.9.2" coil = "3.3.0" jellyfin-sdk = "1.7.1" -nav3Core = "1.0.0" +nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" datastore = "1.2.0" kotlinx-serialization = "1.10.0" protobuf-javalite = "4.33.5" -hilt = "2.58" +hilt = "2.59.2" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" @@ -44,7 +42,7 @@ workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" -openapi-generator = "7.19.0" +openapi-generator = "7.20.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -83,7 +81,6 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" } kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } @@ -135,7 +132,6 @@ androidx-core-testing = { module = "androidx.arch.core:core-testing", version.re [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55b..61285a65 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index aaaabb3c..37f78a6a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 23d15a93..adff685a 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee8..e509b2dd 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell