diff --git a/.github/ISSUE_TEMPLATE/02-new-feature.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml similarity index 75% rename from .github/ISSUE_TEMPLATE/02-new-feature.yml rename to .github/DISCUSSION_TEMPLATE/ideas.yml index 500beea4..5d1b4346 100644 --- a/.github/ISSUE_TEMPLATE/02-new-feature.yml +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -1,6 +1,4 @@ -name: "Feature Request" -description: Request a new feature -title: "[FEA] - " +title: "<title>" labels: [ "enhancement" ] diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml index f67a7db7..1aab31da 100644 --- a/.github/ISSUE_TEMPLATE/00-bug.yml +++ b/.github/ISSUE_TEMPLATE/00-bug.yml @@ -1,6 +1,6 @@ name: "Bug Report" description: Report a bug -title: "[BUG] - <title>" +title: "<title>" labels: [ "bug" ] @@ -28,7 +28,7 @@ body: attributes: label: "App Version" description: What version of the app? - placeholder: "v0.3.0" + placeholder: "v0.5.1" validations: required: true - type: input @@ -36,7 +36,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.3" + placeholder: "10.11.6" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index 2dc73ff6..34dc317b 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -1,6 +1,6 @@ name: "Playback Problem" description: Report an issue with playback -title: "[BUG] - <title>" +title: "<title>" labels: [ "bug", "playback" ] @@ -17,6 +17,7 @@ body: attributes: label: "Media info" description: Please enter details about what media you are trying to play (video or audio codecs, container, etc) + placeholder: Use mediainfo or "Send media info to server" in the app to gather this validations: required: true - type: dropdown @@ -35,7 +36,7 @@ body: attributes: label: "App Version" description: What version of the app? - placeholder: "v0.3.0" + placeholder: "v0.5.1" validations: required: true - type: input @@ -43,7 +44,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.3" + placeholder: "10.11.6" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/03-question.yml b/.github/ISSUE_TEMPLATE/03-question.yml deleted file mode 100644 index bd10bd46..00000000 --- a/.github/ISSUE_TEMPLATE/03-question.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: "Question" -description: Ask a question -title: "[QST] - <title>" -labels: [ - "question" -] -body: - - type: textarea - id: description - attributes: - label: "Question" - description: Please enter your question - placeholder: "How do I...?" - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..2ee91c39 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: New feature / UI suggestion + url: https://github.com/damontecres/Wholphin/discussions/new?category=ideas + about: Please request new features here + - name: Questions + url: https://github.com/damontecres/Wholphin/discussions/new?category=q-a + about: Please ask questions here diff --git a/.github/actions/native-build/action.yml b/.github/actions/native-build/action.yml index 309c6b59..770e552a 100644 --- a/.github/actions/native-build/action.yml +++ b/.github/actions/native-build/action.yml @@ -10,7 +10,7 @@ runs: - name: Load ffmpeg module cache id: cache_ffmpeg_module if: ${{ inputs.cache }} - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | app/libs @@ -18,7 +18,7 @@ runs: - name: Load libmpv module cache id: cache_libmpv_module if: ${{ inputs.cache }} - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | app/src/main/libs @@ -43,7 +43,7 @@ runs: ./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}" - name: Save ffmpeg module cache id: cache_ffmpeg_module_save - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | app/libs @@ -75,7 +75,7 @@ runs: #ln -s libs jniLibs - name: Save libmpv module cache id: cache_libmpv_module_save - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | app/src/main/libs diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 1153cc97..774dae38 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,9 +6,9 @@ runs: steps: # Setup the SDKs - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.14' - name: Setup JDK uses: actions/setup-java@v5 with: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 539f0572..9583525d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 # Need the tags to build - name: Setup diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 56513c4b..0b0f2204 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,13 +16,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 # Need the tags to build - name: Setup Python uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.14' - name: Run pre-commit uses: pre-commit/action@v3.0.1 @@ -31,7 +31,7 @@ jobs: needs: pre-commit steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 # Need the tags to build - name: Setup @@ -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@v5 - id: upload-build-dirs - with: - name: "${{ env.BUILD_DIRS_ARTIFACT }}" - path: build.tgz - if-no-files-found: error - - uses: actions/upload-artifact@v5 - 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 7c5c45f9..9611c810 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 # Need the tags to build - name: Setup @@ -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 + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) + find app/build/outputs \( -name '*.apk' \) -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@v7 + 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..6a3c4c65 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,14 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin </p> -<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" /> +![v0_5_1_home](https://github.com/user-attachments/assets/62bb1703-abdf-4154-9054-e00b6ceb57b5) ## Features ### 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,14 +114,21 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin ## Additional screenshots +### Customized home page +![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1) + ### Movie library browsing -<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" /> +![v0_5_1_library](https://github.com/user-attachments/assets/fad0424b-0631-4438-a8bc-d4fbb95a5bf3) ### Movie page -<img width="1280" height="720" alt="0_3_5_movie" src="https://github.com/user-attachments/assets/86af5889-6761-426a-8649-422f9d0a1dc0" /> +![v0_5_1_movie](https://github.com/user-attachments/assets/849aad34-49d5-4864-8de7-005bbcb68ac6) ### Series page -<img width="1280" height="720" alt="0_3_5_series" src="https://github.com/user-attachments/assets/2dcb2260-53ce-49d6-9088-72cbd4563c48" /> +![v0_5_1_series](https://github.com/user-attachments/assets/655389e1-6a6f-43bc-85e1-e2feffb20429) + +### Genres in library +![v0_5_1_genres](https://github.com/user-attachments/assets/5bbcbeb6-edc9-42c7-a1d8-d92fa432a498) + ### Playlist -<img width="1280" height="771" alt="0 3 0_playlist" src="https://github.com/user-attachments/assets/7ca589ab-9c88-483a-b769-35ffb5663d9e" /> +![v0_5_1_playlist](https://github.com/user-attachments/assets/98268f7d-479d-41c6-b47b-3e67bbe661bc) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 66d09bf8..b5f86522 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) @@ -45,42 +44,9 @@ android { targetSdk = 36 versionCode = gitTags.trim().lines().size versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" } - 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,13 @@ android { sourceSets { getByName("main") { - kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin") + kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" + } + } + + testOptions { + unitTests { + isIncludeAndroidResources = true } } } @@ -223,6 +216,7 @@ dependencies { implementation(libs.androidx.tv.foundation) implementation(libs.androidx.tv.material) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) implementation(libs.protobuf.kotlin.lite) @@ -266,6 +260,8 @@ dependencies { implementation(libs.androidx.preference.ktx) implementation(libs.androidx.room.testing) implementation(libs.androidx.palette.ktx) + implementation(libs.androidx.media3.effect) + implementation(libs.androidx.runner) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) ksp(libs.androidx.hilt.compiler) @@ -284,6 +280,8 @@ dependencies { ksp(libs.auto.service.ksp) implementation(platform(libs.okhttp.bom)) implementation(libs.okhttp) + implementation(libs.kache) + implementation(libs.kache.file) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) @@ -299,5 +297,12 @@ dependencies { testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) + testImplementation(libs.hilt.android.testing) + testImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.hilt.android.testing) + androidTestImplementation(libs.androidx.ui.test.manifest) } diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json new file mode 100644 index 00000000..715fb618 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json @@ -0,0 +1,635 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "f9b86b73e972aa002238d43ab2c8dcc2", + "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, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "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, 'f9b86b73e972aa002238d43ab2c8dcc2')" + ] + } +} 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/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt new file mode 100644 index 00000000..72cf2896 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -0,0 +1,75 @@ +package com.github.damontecres.wholphin.test + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class InstrumentedBasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule<TestActivity>() + + lateinit var screensaverService: ScreensaverService + + @Before + fun setup() { + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun myTest() { + // Start the app + composeTestRule.setContent { + WholphinTheme { + MainContent( + backStack = mutableListOf(SetupDestination.ServerList), + navigationManager = mockk(relaxed = true), + appPreferences = mockk(relaxed = true), + backdropService = mockk(relaxed = true), + screensaverService = screensaverService, + requestedDestination = Destination.Home(), + modifier = Modifier, + ) + } + } + + composeTestRule.onNodeWithText("Add Server").assertExists() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO + } + composeTestRule.onNodeWithTag("add_server").performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertExists() + } +} + +@OptIn(ExperimentalTestApi::class) +fun SemanticsNodeInteraction.performClickEnter() = + performKeyInput { + pressKey(Key.DirectionCenter) + } diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt new file mode 100644 index 00000000..694186ec --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt @@ -0,0 +1,14 @@ +package com.github.damontecres.wholphin.test + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class WholphinTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + name: String?, + context: Context?, + ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..29b2eb75 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,94 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:installLocation="auto"> + + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> + <uses-permission + android:name="android.permission.WRITE_EXTERNAL_STORAGE" + android:maxSdkVersion="28" /> + <uses-permission + android:name="android.permission.READ_EXTERNAL_STORAGE" + android:maxSdkVersion="28" /> + <uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> + + <uses-feature + android:name="android.hardware.touchscreen" + android:required="false" /> + <uses-feature + android:name="android.software.leanback" + android:required="false" /> + <uses-feature + android:name="android.hardware.microphone" + android:required="false" /> + + <!-- Required for Android 11+ to query voice recognition availability --> + <queries> + <intent> + <action android:name="android.speech.action.RECOGNIZE_SPEECH" /> + </intent> + </queries> + + <application + android:allowBackup="true" + android:banner="@mipmap/ic_banner" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@style/Theme.Wholphin" + android:name=".WholphinApplication" + android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config"> + <activity + android:name=".MainActivity" + android:exported="true" + android:launchMode="singleTask" + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> + </intent-filter> + </activity> + + <activity android:name=".test.TestActivity" + android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> + </intent-filter> + </activity> + + <provider + android:name="androidx.core.content.FileProvider" + android:authorities="${applicationId}.provider" + android:exported="false" + android:grantUriPermissions="true"> + <meta-data + android:name="android.support.FILE_PROVIDER_PATHS" + android:resource="@xml/provider_paths" /> + </provider> + <provider + android:name="androidx.startup.InitializationProvider" + android:authorities="${applicationId}.androidx-startup" + tools:node="remove" /> + +<!-- <service--> +<!-- android:name=".WholphinDreamService"--> +<!-- android:exported="true"--> +<!-- android:label="@string/app_name"--> +<!-- android:permission="android.permission.BIND_DREAM_SERVICE">--> +<!-- <intent-filter>--> +<!-- <action android:name="android.service.dreams.DreamService" />--> +<!-- <category android:name="android.intent.category.DEFAULT" />--> +<!-- </intent-filter>--> +<!-- </service>--> + </application> + +</manifest> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 161352af..4479ae86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,17 @@ android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" tools:node="remove" /> + + <service + android:name=".WholphinDreamService" + android:exported="true" + android:label="@string/app_name" + android:permission="android.permission.BIND_DREAM_SERVICE"> + <intent-filter> + <action android:name="android.service.dreams.DreamService" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + </service> </application> </manifest> 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 0f8b7ee4..e2afbfa0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin import android.content.Intent import android.content.res.Configuration import android.os.Bundle +import android.view.KeyEvent import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -10,8 +11,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -19,27 +18,16 @@ 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.graphics.RectangleShape -import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.Color import androidx.datastore.core.DataStore -import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService @@ -48,27 +36,36 @@ import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService +import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.SuggestionsSchedulerService import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setup.SwitchServerContent -import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree +import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -113,6 +110,12 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var tvProviderSchedulerService: TvProviderSchedulerService + @Inject + lateinit var suggestionsSchedulerService: SuggestionsSchedulerService + + @Inject + lateinit var backdropService: BackdropService + // Note: unused but injected to ensure it is created @Inject lateinit var serverEventListener: ServerEventListener @@ -121,22 +124,20 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var datePlayedInvalidationService: DatePlayedInvalidationService + @Inject + lateinit var screensaverService: ScreensaverService + private var signInAuto = true @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + instance = this Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) if (savedInstanceState == null) { - appUpgradeHandler.copySubfont(false) - } - refreshRateService.refreshRateMode.observe(this) { modeId -> - // Listen for refresh rate changes - val attrs = window.attributes - if (attrs.preferredDisplayModeId != modeId) { - Timber.d("Switch preferredDisplayModeId to %s", modeId) - window.attributes = attrs.apply { preferredDisplayModeId = modeId } + lifecycleScope.launchIO { + appUpgradeHandler.copySubfont(false) } } viewModel.serverRepository.currentUser.observe(this) { user -> @@ -149,9 +150,42 @@ class MainActivity : AppCompatActivity() { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } + screensaverService.keepScreenOn + .onEach { keepScreenOn -> + Timber.v("keepScreenOn: %s", keepScreenOn) + withContext(Dispatchers.Main) { + if (keepScreenOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + }.catch { ex -> + Timber.e(ex, "Error with keepScreenOn") + }.launchIn(lifecycleScope) + viewModel.appStart() setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) + if (appPreferences == null) { + // Show loading page if it is taking a while to get app preferences + var showLoading by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(500) + Timber.i("Showing loading page") + showLoading = true + } + if (showLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + LoadingPage() + } + } + } appPreferences?.let { appPreferences -> LaunchedEffect(appPreferences.signInAutomatically) { signInAuto = appPreferences.signInAutomatically @@ -177,127 +211,41 @@ class MainActivity : AppCompatActivity() { true, appThemeColors = appPreferences.interfacePreferences.appThemeColors, ) { - Surface( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - shape = RectangleShape, - ) { -// val backStack = rememberNavBackStack(SetupDestination.Loading) -// setupNavigationManager.backStack = backStack - val backStack = setupNavigationManager.backStack - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryDecorators = - listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = { key -> - key as SetupDestination - NavEntry(key) { - when (key) { - SetupDestination.Loading -> { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - - SetupDestination.ServerList -> { - SwitchServerContent(Modifier.fillMaxSize()) - } - - is SetupDestination.UserList -> { - SwitchUserContent( - currentServer = key.server, - Modifier.fillMaxSize(), - ) - } - - is SetupDestination.AppContent -> { - val current = key.current - ProvideLocalClock { - if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { - LaunchedEffect(Unit) { - try { - updateChecker.maybeShowUpdateToast( - appPreferences.updateUrl, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Exception during update check", - ) - } - } - } - val appPreferences by userPreferencesDataStore.data.collectAsState( - appPreferences, - ) - val preferences = - remember(appPreferences) { - UserPreferences(appPreferences) - } - var showContent by remember { - mutableStateOf(true) - } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!preferences.appPreferences.signInAutomatically) { - showContent = false - } - } - - if (showContent) { - val requestedDestination = - remember(intent) { - intent?.let(::extractDestination) - } - ApplicationContent( - user = current.user, - server = current.server, - startDestination = - requestedDestination - ?: Destination.Home(), - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) - } else { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - } - } - } - } - }, - ) - } + val requestedDestination = + remember(intent) { + intent?.let(::extractDestination) ?: Destination.Home() + } + MainContent( + backStack = setupNavigationManager.backStack, + navigationManager = navigationManager, + appPreferences = appPreferences, + backdropService = backdropService, + screensaverService = screensaverService, + requestedDestination = requestedDestination, + modifier = Modifier.fillMaxSize(), + ) } } } } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (screensaverService.state.value.show) { + screensaverService.stop(false) + screensaverService.pulse() + return true + } else { + screensaverService.pulse() + return super.dispatchKeyEvent(event) + } + } + override fun onResume() { super.onResume() Timber.d("onResume") - lifecycleScope.launchIO { + lifecycleScope.launchDefault { + screensaverService.pulse() appUpgradeHandler.run() } } @@ -311,6 +259,7 @@ class MainActivity : AppCompatActivity() { override fun onStop() { super.onStop() Timber.d("onStop") + screensaverService.stop(true) tvProviderSchedulerService.launchOneTimeRefresh() } @@ -322,6 +271,22 @@ class MainActivity : AppCompatActivity() { override fun onStart() { super.onStart() Timber.d("onStart") + + lifecycleScope.launchDefault { + val appPreferences = userPreferencesDataStore.data.first() + if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { + try { + updateChecker.maybeShowUpdateToast( + appPreferences.updateUrl, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Exception during update check", + ) + } + } + } } override fun onSaveInstanceState(outState: Bundle) { @@ -384,6 +349,16 @@ class MainActivity : AppCompatActivity() { } } + fun changeDisplayMode(modeId: Int) { + lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) { + val attrs = window.attributes + if (attrs.preferredDisplayModeId != modeId) { + Timber.d("Switch preferredDisplayModeId to %s", modeId) + window.attributes = attrs.apply { preferredDisplayModeId = modeId } + } + } + } + companion object { const val INTENT_ITEM_ID = "itemId" const val INTENT_ITEM_TYPE = "itemType" @@ -391,6 +366,9 @@ class MainActivity : AppCompatActivity() { const val INTENT_EPISODE_NUMBER = "epNum" const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_ID = "seaId" + + lateinit var instance: MainActivity + private set } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt new file mode 100644 index 00000000..982240a4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.components.AppScreensaver +import com.github.damontecres.wholphin.ui.nav.ApplicationContent +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchUserContent +import com.github.damontecres.wholphin.ui.util.ProvideLocalClock + +@Composable +fun MainContent( + backStack: MutableList<SetupDestination>, + navigationManager: NavigationManager, + appPreferences: AppPreferences, + backdropService: BackdropService, + screensaverService: ScreensaverService, + requestedDestination: Destination, + modifier: Modifier = Modifier, +) { + Surface( + modifier = + modifier + .background(MaterialTheme.colorScheme.background), + shape = RectangleShape, + ) { +// val backStack = rememberNavBackStack(SetupDestination.Loading) +// setupNavigationManager.backStack = backStack + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = { key -> + key as SetupDestination + NavEntry(key) { + when (key) { + SetupDestination.Loading -> { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } + + SetupDestination.ServerList -> { + SwitchServerContent(Modifier.fillMaxSize()) + } + + is SetupDestination.UserList -> { + SwitchUserContent( + currentServer = key.server, + Modifier.fillMaxSize(), + ) + } + + is SetupDestination.AppContent -> { + LaunchedEffect(Unit) { + backdropService.clearBackdrop() + } + val current = key.current + ProvideLocalClock { + val preferences = + remember(appPreferences) { + UserPreferences(appPreferences) + } + var showContent by remember { + mutableStateOf(true) + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + if (!appPreferences.signInAutomatically) { + showContent = false + } + } + + if (showContent) { + ApplicationContent( + user = current.user, + server = current.server, + startDestination = requestedDestination, + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } + } + } + }, + ) + val screenSaverState by screensaverService.state.collectAsState() + if (screenSaverState.enabled || screenSaverState.enabledTemp) { + AnimatedVisibility( + screenSaverState.show, + Modifier.fillMaxSize(), + ) { + AppScreensaver(appPreferences, Modifier.fillMaxSize()) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index cf2c159a..ceb7264e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin import android.app.Application import android.os.Build import android.os.StrictMode -import android.os.StrictMode.ThreadPolicy import android.util.Log import androidx.compose.runtime.Composer import androidx.compose.runtime.ExperimentalComposeRuntimeApi @@ -27,16 +26,6 @@ class WholphinApplication : init { instance = this - if (BuildConfig.DEBUG) { - StrictMode.setThreadPolicy( - ThreadPolicy - .Builder() - .detectNetwork() - .penaltyLog() - .build(), - ) - } - if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } else { @@ -64,6 +53,23 @@ class WholphinApplication : override fun onCreate() { super.onCreate() + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy + .Builder() + .detectNetwork() + .penaltyLog() + .penaltyDeathOnNetwork() + .build(), + ) +// StrictMode.setVmPolicy( +// StrictMode.VmPolicy +// .Builder() +// .detectAll() +// .penaltyLog() +// .build(), +// ) + } OkHttp.initialize(this) initAcra { buildConfigClass = BuildConfig::class.java diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt new file mode 100644 index 00000000..4d77c3a8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -0,0 +1,101 @@ +package com.github.damontecres.wholphin + +import android.service.dreams.DreamService +import androidx.compose.foundation.layout.fillMaxSize +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.components.AppScreensaverContent +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.ProvideLocalClock +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@AndroidEntryPoint +class WholphinDreamService : + DreamService(), + SavedStateRegistryOwner { + @Inject + lateinit var screensaverService: ScreensaverService + + @Inject + lateinit var userPreferencesService: UserPreferencesService + + private val lifecycleRegistry = LifecycleRegistry(this) + + private val savedStateRegistryController = + SavedStateRegistryController.create(this).apply { + performAttach() + } + + override val lifecycle: Lifecycle get() = lifecycleRegistry + override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry + + override fun onCreate() { + super.onCreate() + + savedStateRegistryController.performRestore(null) + lifecycleRegistry.currentState = Lifecycle.State.CREATED + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val itemFlow = screensaverService.createItemFlow(lifecycleScope) + setContentView( + ComposeView(this).apply { + setViewTreeLifecycleOwner(this@WholphinDreamService) + setViewTreeSavedStateRegistryOwner(this@WholphinDreamService) + setContent { + var prefs by remember { mutableStateOf<UserPreferences?>(null) } + LaunchedEffect(Unit) { + userPreferencesService.flow.collectLatest { prefs = it } + } + prefs?.let { prefs -> + WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) { + ProvideLocalClock { + val screensaverPrefs = + prefs.appPreferences.interfacePreferences.screensaverPreference + val currentItem by itemFlow.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = screensaverPrefs.showClock, + duration = screensaverPrefs.duration.milliseconds, + animate = screensaverPrefs.animate, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } + }, + ) + } + + override fun onDreamingStarted() { + super.onDreamingStarted() + lifecycleRegistry.currentState = Lifecycle.State.STARTED + } + + override fun onDreamingStopped() { + super.onDreamingStopped() + lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + } +} 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 19e33616..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 @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser @@ -32,12 +33,14 @@ import java.util.UUID ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class, + PlaybackEffect::class, PlaybackLanguageChoice::class, ItemTrackModification::class, SeerrServer::class, SeerrUser::class, + ], - version = 20, + version = 31, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -50,6 +53,8 @@ import java.util.UUID AutoMigration(10, 11), AutoMigration(11, 12), AutoMigration(12, 20), + AutoMigration(20, 30), + AutoMigration(30, 31), ], ) @TypeConverters(Converters::class) @@ -65,6 +70,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao abstract fun seerrServerDao(): SeerrServerDao + + abstract fun playbackEffectDao(): PlaybackEffectDao } class Converters { 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 2312c62c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.github.damontecres.wholphin.data - -import android.content.Context -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem -import com.github.damontecres.wholphin.data.model.NavPinType -import com.github.damontecres.wholphin.services.SeerrServerRepository -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem -import com.github.damontecres.wholphin.util.supportedCollectionTypes -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.first -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.liveTvApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.model.api.CollectionType -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class NavDrawerItemRepository - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val serverPreferencesDao: ServerPreferencesDao, - private val seerrServerRepository: SeerrServerRepository, - ) { - suspend fun getNavDrawerItems(): List<NavDrawerItem> { - val user = serverRepository.currentUser.value - val tvAccess = - serverRepository.currentUserDto.value - ?.policy - ?.enableLiveTvAccess ?: false - val userViews = - api.userViewsApi - .getUserViews(userId = user?.id) - .content.items - val recordingFolders = - if (tvAccess) { - api.liveTvApi - .getRecordingFolders(userId = user?.id) - .content.items - .map { it.id } - .toSet() - } else { - setOf() - } - - val builtins = - if (seerrServerRepository.active.first()) { - listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) - } else { - listOf(NavDrawerItem.Favorites) - } - - val libraries = - userViews - .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } - .map { - val destination = - if (it.id in recordingFolders) { - Destination.Recordings(it.id) - } else { - BaseItem.from(it, api).destination() - } - ServerNavDrawerItem( - itemId = it.id, - name = it.name ?: it.id.toString(), - destination = destination, - type = it.collectionType ?: CollectionType.UNKNOWN, - ) - } - return builtins + libraries - } - - suspend fun getFilteredNavDrawerItems(items: List<NavDrawerItem>): List<NavDrawerItem> { - val user = serverRepository.currentUser.value - val navDrawerPins = - user - ?.let { - serverPreferencesDao.getNavDrawerPinnedItems(it) - }.orEmpty() - val filtered = items.filter { navDrawerPins.isPinned(it.id) } - if (items.size != filtered.size) { - // Some were filtered out, check if should include More - if (navDrawerPins.isPinned(NavDrawerItem.More.id)) { - return filtered + listOf(NavDrawerItem.More) - } - } - return filtered - } - } - -fun List<NavDrawerPinnedItem>.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt new file mode 100644 index 00000000..0fa9f0ee --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt @@ -0,0 +1,22 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.damontecres.wholphin.data.model.PlaybackEffect +import org.jellyfin.sdk.model.api.BaseItemKind +import java.util.UUID + +@Dao +interface PlaybackEffectDao { + @Query("SELECT * FROM playback_effects WHERE jellyfinUserRowId=:jellyfinUserRowId AND itemId=:itemId AND type=:type") + suspend fun getPlaybackEffect( + jellyfinUserRowId: Int, + itemId: UUID, + type: BaseItemKind, + ): PlaybackEffect? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(playbackEffect: PlaybackEffect) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt index 59f5d9ea..3a2c325c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt @@ -47,7 +47,7 @@ interface SeerrServerDao { suspend fun deleteUser( serverId: Int, jellyfinUserRowId: Int, - ) + ): Int suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId) 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..2d786ed8 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 @@ -9,15 +9,18 @@ import androidx.lifecycle.map import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers 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 @@ -40,9 +43,8 @@ class ServerRepository val serverDao: JellyfinServerDao, val apiClient: ApiClient, val userPreferencesDataStore: DataStore<AppPreferences>, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { - private val sharedPreferences = getServerSharedPreferences(context) - private var _current = EqualityMutableLiveData<CurrentUser?>(null) val current: LiveData<CurrentUser?> = _current @@ -58,7 +60,7 @@ class ServerRepository * The current user is removed */ suspend fun addAndChangeServer(server: JellyfinServer) { - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.addOrUpdateServer(server) } apiClient.update(baseUrl = server.url, accessToken = null) @@ -72,7 +74,7 @@ class ServerRepository server: JellyfinServer, user: JellyfinUser, ): CurrentUser? = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { if (server.id != user.serverId) { throw IllegalStateException("User is not part of the server") } @@ -106,7 +108,7 @@ class ServerRepository _current.value = CurrentUser(updatedServer, updatedUser) _currentUserDto.value = userDto } - sharedPreferences.edit(true) { + getServerSharedPreferences(context).edit(true) { putString(SERVER_URL_KEY, updatedServer.url) putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) } @@ -127,7 +129,7 @@ class ServerRepository return null } val serverAndUsers = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.getServer(serverId) } if (serverAndUsers != null) { @@ -150,7 +152,7 @@ class ServerRepository } suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverId?.let { serverDao.getServer(serverId)?.server } } @@ -164,7 +166,7 @@ class ServerRepository suspend fun changeUser( serverUrl: String, authenticationResult: AuthenticationResult, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { val accessToken = authenticationResult.accessToken if (accessToken != null) { val authedUser = authenticationResult.user @@ -214,7 +216,7 @@ class ServerRepository } apiClient.update(accessToken = null) } - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteUser(user.serverId, user.id) } } @@ -234,7 +236,7 @@ class ServerRepository } apiClient.update(baseUrl = null, accessToken = null) } - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteServer(server.id) } } @@ -253,7 +255,7 @@ class ServerRepository suspend fun setUserPin( user: JellyfinUser, pin: String?, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { val newUser = user.copy(pin = pin) val updatedUser = serverDao.addOrUpdateUser(newUser) if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { @@ -265,6 +267,17 @@ class ServerRepository } } + suspend fun authorizeQuickConnect(code: String): Boolean = + withContext(ioDispatcher) { + 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 a9673131..3c34851b 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 @@ -69,6 +72,8 @@ data class BaseItem( } } + val canDelete: Boolean get() = data.canDelete == true + @Transient val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } @@ -91,12 +96,20 @@ data class BaseItem( episodeCornerText = data.indexNumber?.let { "E$it" } ?: data.premiereDate?.let(::formatDateTime), - episdodeUnplayedCornerText = - data.indexNumber?.let { "E$it" } - ?: data.userData - ?.unplayedItemCount - ?.takeIf { it > 0 } - ?.let { abbreviateNumber(it) }, + episodeUnplayedCornerText = + if (type == BaseItemKind.SERIES || + type == BaseItemKind.SEASON || + type == BaseItemKind.EPISODE || + type == BaseItemKind.BOX_SET + ) { + data.indexNumber?.let { "E$it" } + ?: data.userData + ?.unplayedItemCount + ?.takeIf { it > 0 } + ?.let { abbreviateNumber(it) } + } else { + null + }, quickDetails = buildAnnotatedString { val details = @@ -106,6 +119,12 @@ data class BaseItem( data.premiereDate?.let { add(DateFormatter.format(it)) } } else if (type == BaseItemKind.SERIES) { data.seriesProductionYears?.let(::add) + } else if (type == BaseItemKind.PHOTO) { + if (data.productionYear != null) { + add(data.productionYear!!.toString()) + } else if (data.premiereDate != null) { + add(data.premiereDate!!.toLocalDate().toString()) + } } else { data.productionYear?.let { add(it.toString()) } } @@ -154,7 +173,8 @@ data class BaseItem( it.dayOfMonth.toString().padStart(2, '0') }?.toIntOrNull() - fun destination(): Destination { + fun destination(index: Int? = null): Destination { + if (destinationOverride != null) return destinationOverride val result = // Redirect episodes & seasons to their series if possible when (type) { @@ -176,6 +196,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) } @@ -201,6 +240,31 @@ val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { @Immutable data class BaseItemUi( val episodeCornerText: String?, - val episdodeUnplayedCornerText: String?, + val episodeUnplayedCornerText: String?, val quickDetails: AnnotatedString, ) + +fun createGenreDestination( + genreId: UUID, + genreName: String, + parentId: UUID, + parentName: String?, + includeItemTypes: List<BaseItemKind>?, +) = Destination.FilteredCollection( + itemId = parentId, + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + genreName, + parentName, + ).joinToString(" "), + filter = + GetItemsFilter( + genres = listOf(genreId), + includeItemTypes = includeItemTypes, + ), + useSavedLibraryDisplayInfo = false, + ), + recursive = true, +) 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<HomeRowConfig>, + val version: Int, +) { + companion object { + val EMPTY = HomePageSettings(listOf(), SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + } +} + +/** + * This is the max version supported by this version of the app + */ +const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1 + +/** + * View options for displaying a row + * + * Allows for changing things like height or aspect ratio + */ +@Serializable +data class HomeRowViewOptions( + val heightDp: Int = Cards.HEIGHT_2X3_DP, + val spacing: Int = 16, + val contentScale: PrefContentScale = PrefContentScale.FILL, + val aspectRatio: AspectRatio = AspectRatio.TALL, + val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, + val showTitles: Boolean = false, + val useSeries: Boolean = true, + val episodeContentScale: PrefContentScale = PrefContentScale.FILL, + val episodeAspectRatio: AspectRatio = AspectRatio.TALL, + val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, +) { + companion object { + val genreDefault = + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + ) + + val liveTvDefault = + HomeRowViewOptions( + heightDp = 96, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.FIT, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt index c12d37b5..ac4a4710 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt @@ -1,6 +1,10 @@ package com.github.damontecres.wholphin.data.model +import android.content.Context +import androidx.annotation.StringRes import androidx.compose.runtime.Stable +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.UUID @@ -19,19 +23,21 @@ data class Person( ) { companion object { fun fromDto( + context: Context, dto: BaseItemPerson, api: ApiClient, ): Person = Person( id = dto.id, name = dto.name, - role = dto.role, + role = personRole(context, dto.role, dto.type), type = dto.type, imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), favorite = false, ) fun fromDto( + context: Context, dto: BaseItemPerson, favorite: Boolean, api: ApiClient, @@ -39,10 +45,51 @@ data class Person( Person( id = dto.id, name = dto.name, - role = dto.role, + role = personRole(context, dto.role, dto.type), type = dto.type, imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), favorite = favorite, ) } } + +private fun personRole( + context: Context, + role: String?, + type: PersonKind, +): String? = + if (type == PersonKind.ACTOR || type == PersonKind.GUEST_STAR || type == PersonKind.UNKNOWN) { + role + } else if (role.equals(type.name, ignoreCase = true)) { + type.stringRes?.let { context.getString(it) } + } else if (role.isNotNullOrBlank() && role.lowercase().contains(type.name.lowercase())) { + role + } else { + listOfNotNull( + role?.takeIf { it.isNotNullOrBlank() }, + type.stringRes?.let { context.getString(it) }, + ).takeIf { it.isNotEmpty() }?.joinToString(" - ") + } + +@get:StringRes +private val PersonKind.stringRes: Int? + get() = + when (this) { + PersonKind.UNKNOWN -> R.string.unknown + PersonKind.ACTOR -> R.string.actor + PersonKind.DIRECTOR -> R.string.director + PersonKind.COMPOSER -> R.string.composer + PersonKind.WRITER -> R.string.writer + PersonKind.GUEST_STAR -> R.string.guest_star + PersonKind.PRODUCER -> R.string.producer + PersonKind.CONDUCTOR -> R.string.conductor + PersonKind.LYRICIST -> R.string.lyricist + PersonKind.ARRANGER -> R.string.arranger + PersonKind.ENGINEER -> R.string.engineer + PersonKind.MIXER -> R.string.mixer + PersonKind.REMIXER -> R.string.mixer + PersonKind.CREATOR -> R.string.creator + PersonKind.ARTIST -> R.string.artist + PersonKind.ALBUM_ARTIST -> R.string.artist + else -> null + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt new file mode 100644 index 00000000..3c5cf6a0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt @@ -0,0 +1,14 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.room.Embedded +import androidx.room.Entity +import org.jellyfin.sdk.model.api.BaseItemKind +import java.util.UUID + +@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"]) +data class PlaybackEffect( + val jellyfinUserRowId: Int, + val itemId: UUID, + val type: BaseItemKind, + @Embedded val videoFilter: VideoFilter, +) 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/data/model/VideoFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt new file mode 100644 index 00000000..e71db95c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt @@ -0,0 +1,223 @@ +package com.github.damontecres.wholphin.data.model + +import android.graphics.ColorMatrix +import androidx.annotation.IntRange +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.effect.Brightness +import androidx.media3.effect.Contrast +import androidx.media3.effect.GaussianBlur +import androidx.media3.effect.GlEffect +import androidx.media3.effect.HslAdjustment +import androidx.media3.effect.RgbAdjustment +import androidx.media3.effect.ScaleAndRotateTransformation +import androidx.room.Ignore + +/** + * Modifications to a video playback + */ +data class VideoFilter( + val rotation: Int = 0, + @param:IntRange(0, 200) val brightness: Int = COLOR_DEFAULT, + @param:IntRange(0, 200) val contrast: Int = COLOR_DEFAULT, + @param:IntRange(0, 200) val saturation: Int = COLOR_DEFAULT, + @param:IntRange(0, 360) val hue: Int = HUE_DEFAULT, + @param:IntRange(0, 200) val red: Int = COLOR_DEFAULT, + @param:IntRange(0, 200) val green: Int = COLOR_DEFAULT, + @param:IntRange(0, 200) val blue: Int = COLOR_DEFAULT, + @param:IntRange(0, 250) val blur: Int = 0, +) { + @Ignore + val colorMatrix: androidx.compose.ui.graphics.ColorMatrix = createComposeColorMatrix() + + companion object { + const val COLOR_DEFAULT = 100 + const val HUE_DEFAULT = 0 + } + + fun isRotated(): Boolean = rotation != 0 && rotation % 360 != 0 + + fun hasRgb(): Boolean = red != COLOR_DEFAULT || green != COLOR_DEFAULT || blue != COLOR_DEFAULT + + fun hasBrightness(): Boolean = brightness != COLOR_DEFAULT + + fun hasContrast(): Boolean = contrast != COLOR_DEFAULT + + fun hasHsl(): Boolean = hue != HUE_DEFAULT || saturation != COLOR_DEFAULT + + fun hasBlur(): Boolean = blur > 0 + + fun hasImageFilter(): Boolean = hasRgb() || hasBrightness() || hasContrast() || saturation != COLOR_DEFAULT + + @OptIn(UnstableApi::class) + private fun rgbAdjustment(): RgbAdjustment = + RgbAdjustment + .Builder() + .setRedScale(red / COLOR_DEFAULT.toFloat()) + .setGreenScale(green / COLOR_DEFAULT.toFloat()) + .setBlueScale(blue / COLOR_DEFAULT.toFloat()) + .build() + + /** + * Create the list of effects to apply + */ + @OptIn(UnstableApi::class) + fun createEffectList(): List<GlEffect> = + buildList { + if (isRotated()) { + add( + ScaleAndRotateTransformation + .Builder() + .setRotationDegrees(rotation.toFloat()) + .build(), + ) + } + if (hasRgb()) { + add(rgbAdjustment()) + } + if (hasBrightness()) { + add(Brightness((brightness - 100) / 100f)) + } + if (hasContrast()) { + add(Contrast((contrast - 100) / 100f)) + } + if (hasHsl()) { + add( + HslAdjustment + .Builder() + .adjustHue(hue.toFloat()) + .adjustSaturation((saturation - 100).toFloat()) + .build(), + ) + } + if (hasBlur()) { + add(GaussianBlur(blur / 10f)) + } + } + + private fun saturationMatrix(): FloatArray { + val rF = 0.2999f + val gF = 0.587f + val bF = 0.114f + val s = saturation / 100.0f + + val ms = 1.0f - s + val rT = rF * ms + val gT = gF * ms + val bT = bF * ms + + val m = + FloatArray(20) { + when (it) { + 0 -> (rT + s) + 1 -> gT + 2 -> bT + 5 -> rT + 6 -> (gT + s) + 7 -> bT + 10 -> rT + 11 -> gT + 12 -> (bT + s) + 18 -> 1f + else -> 0f + } + } + return m + } + + @OptIn(UnstableApi::class) + fun createColorMatrix(): ColorMatrix { + val matrix = ColorMatrix() + val tempMatrix = ColorMatrix() + + if (saturation != COLOR_DEFAULT) { + matrix.set(saturationMatrix()) + } + if (hasRgb()) { + val colorMatrix = rgbAdjustment().getMatrix(0L, false) + val m = FloatArray(20) + m[0] = colorMatrix[0] + m[1] = colorMatrix[1] + m[2] = colorMatrix[2] + m[3] = colorMatrix[3] + m[4] = 0f + m[5] = colorMatrix[4] + m[6] = colorMatrix[5] + m[7] = colorMatrix[6] + m[8] = colorMatrix[7] + m[9] = 0f + m[10] = colorMatrix[8] + m[11] = colorMatrix[9] + m[12] = colorMatrix[10] + m[13] = colorMatrix[11] + m[14] = 0f + m[15] = colorMatrix[12] + m[16] = colorMatrix[13] + m[17] = colorMatrix[14] + m[18] = colorMatrix[15] + m[19] = 0f + tempMatrix.set(m) + matrix.postConcat(tempMatrix) + } + if (hasContrast()) { + val scale = contrast / 100.0f + tempMatrix.setScale(scale, scale, scale, 1f) + matrix.postConcat(tempMatrix) + } + if (hasBrightness()) { + val b = brightness / 100.0f + val m = FloatArray(20) + m[0] = b + m[6] = b + m[12] = b + m[18] = 1f + tempMatrix.set(m) + matrix.postConcat(tempMatrix) + } + // TODO hue + // TODO blur + return matrix + } + + @OptIn(UnstableApi::class) + fun createComposeColorMatrix(): androidx.compose.ui.graphics.ColorMatrix { + val matrix = + androidx.compose.ui.graphics + .ColorMatrix() + + if (saturation != COLOR_DEFAULT) { + matrix.setToSaturation(saturation / 100f) + } + if (hasRgb()) { + matrix.setToScale( + redScale = red / 100f, + greenScale = green / 100f, + blueScale = blue / 100f, + alphaScale = 1f, + ) + } + if (hasContrast()) { + val scale = contrast / 100.0f + val tempMatrix = + androidx.compose.ui.graphics + .ColorMatrix() + tempMatrix.setToScale(scale, scale, scale, 1f) + matrix.timesAssign(tempMatrix) + } + if (hasBrightness()) { + val b = brightness / 100.0f + val m = FloatArray(20) + m[0] = b + m[6] = b + m[12] = b + m[18] = 1f + val tempMatrix = + androidx.compose.ui.graphics + .ColorMatrix(m) + matrix.timesAssign(tempMatrix) + } + // TODO hue + // TODO blur + return matrix + } +} 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 70e83692..ef3f14e7 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 @@ -186,6 +186,45 @@ sealed interface AppPreference<Pref, T> { summarizer = { value -> value?.toString() }, ) + val MaxDaysNextUpOptions = listOf(7, 14, 30, 60, 90, 180, 365) + val MaxDaysNextUp = + AppSliderPreference<AppPreferences>( + title = R.string.max_days_next_up, + defaultValue = -1, + min = 0, + // Max is "no limit" stored as -1 + max = MaxDaysNextUpOptions.lastIndex + 1L, + interval = 1, + getter = { + MaxDaysNextUpOptions + .indexOf(it.homePagePreferences.maxDaysNextUp) + .takeIf { it in MaxDaysNextUpOptions.indices } + ?.toLong() + ?: MaxDaysNextUpOptions.size.toLong() + }, + setter = { prefs, index -> + prefs.updateHomePagePreferences { + maxDaysNextUp = MaxDaysNextUpOptions.getOrNull(index.toInt()) ?: -1 + } + }, + summarizer = { value -> + if (value != null) { + val v = MaxDaysNextUpOptions.getOrNull(value.toInt()) ?: -1 + if (v == -1) { + WholphinApplication.instance.getString(R.string.no_limit) + } else { + WholphinApplication.instance.resources.getQuantityString( + R.plurals.days, + v, + v.toString(), + ) + } + } else { + null + } + }, + ) + val CombineContinueNext = AppSwitchPreference<AppPreferences>( title = R.string.combine_continue_next, @@ -617,6 +656,13 @@ sealed interface AppPreference<Pref, T> { setter = { prefs, _ -> prefs }, ) + val CustomizeHome = + AppDestinationPreference<AppPreferences>( + title = R.string.customize_home, + destination = Destination.HomeSettings, + summary = R.string.customize_home_summary, + ) + val SendCrashReports = AppSwitchPreference<AppPreferences>( title = R.string.send_crash_reports, @@ -695,6 +741,18 @@ sealed interface AppPreference<Pref, T> { valueToIndex = { it.number }, ) + val ManageMedia = + AppSwitchPreference<AppPreferences>( + title = R.string.show_media_management, + defaultValue = false, + getter = { it.interfacePreferences.enableMediaManagement }, + setter = { prefs, value -> + prefs.updateInterfacePreferences { enableMediaManagement = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val OneClickPause = AppSwitchPreference<AppPreferences>( title = R.string.one_click_pause, @@ -710,7 +768,7 @@ sealed interface AppPreference<Pref, T> { val SubtitleStyle = AppDestinationPreference<AppPreferences>( title = R.string.subtitle_style, - destination = Destination.Settings(PreferenceScreenOption.SUBTITLES), + destination = Destination.SubtitleSettings(false), ) val RefreshRateSwitching = @@ -903,6 +961,60 @@ sealed interface AppPreference<Pref, T> { getter = { }, setter = { prefs, _ -> prefs }, ) + + val QuickConnect = + AppClickablePreference<AppPreferences>( + title = R.string.quick_connect, + summary = R.string.quick_connect_summary, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + + val SlideshowDuration = + AppSliderPreference<AppPreferences>( + title = R.string.slideshow_duration, + defaultValue = 5_000, + min = 1_000, + max = 30.seconds.inWholeMilliseconds, + interval = 250, + getter = { + it.photoPreferences.slideshowDuration + }, + setter = { prefs, value -> + prefs.updatePhotoPreferences { + slideshowDuration = value + } + }, + summarizer = { value -> + if (value != null) { + val seconds = value / 1000.0 + WholphinApplication.instance.resources.getString( + R.string.decimal_seconds, + seconds, + ) + } else { + null + } + }, + ) + + val SlideshowPlayVideos = + AppSwitchPreference<AppPreferences>( + title = R.string.play_videos_during_slideshow, + defaultValue = false, + getter = { it.photoPreferences.slideshowPlayVideos }, + setter = { prefs, value -> + prefs.updatePhotoPreferences { slideshowPlayVideos = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + val ScreensaverSettings = + AppDestinationPreference<AppPreferences>( + title = R.string.screensaver_settings, + destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER), + ) } } @@ -913,13 +1025,11 @@ val basicPreferences = preferences = listOf( AppPreference.SignInAuto, - AppPreference.HomePageItems, - AppPreference.CombineContinueNext, - AppPreference.RewatchNextUp, AppPreference.PlayThemeMusic, AppPreference.RememberSelectedTab, AppPreference.SubtitleStyle, AppPreference.ThemeColors, + AppPreference.ScreensaverSettings, ), ), PreferenceGroup( @@ -946,6 +1056,7 @@ val basicPreferences = preferences = listOf( AppPreference.RequireProfilePin, + AppPreference.CustomizeHome, AppPreference.UserPinnedNavDrawerItems, ), ), @@ -969,8 +1080,6 @@ val basicPreferences = ), ) -val uiPreferences = listOf<PreferenceGroup>() - private val ExoPlayerSettings = listOf( AppPreference.FfmpegPreference, @@ -1013,10 +1122,14 @@ val advancedPreferences = preferences = listOf( AppPreference.ShowClock, + AppPreference.ManageMedia, + AppPreference.CombineContinueNext, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, AppPreference.ControllerTimeout, AppPreference.BackdropStylePref, + AppPreference.SlideshowDuration, + AppPreference.SlideshowPlayVideos, ), ), ) @@ -1088,6 +1201,7 @@ val advancedPreferences = title = R.string.more, preferences = listOf( + AppPreference.QuickConnect, AppPreference.SendAppLogs, AppPreference.SendCrashReports, AppPreference.DebugLogging, @@ -1107,6 +1221,24 @@ val liveTvPreferences = AppPreference.LiveTvColorCodePrograms, ) +val screensaverPreferences = + listOf( + PreferenceGroup( + title = R.string.screensaver, + preferences = + listOf( + ScreensaverPreference.Enabled, + ScreensaverPreference.StartDelay, + ScreensaverPreference.Duration, + ScreensaverPreference.ShowClock, + ScreensaverPreference.Animate, + ScreensaverPreference.MaxAge, + ScreensaverPreference.ItemTypes, + ScreensaverPreference.Start, + ), + ), + ) + data class AppSwitchPreference<Pref>( @get:StringRes override val title: Int, override val defaultValue: Boolean, @@ -1161,8 +1293,6 @@ data class AppMultiChoicePreference<Pref, T>( override val getter: (prefs: Pref) -> List<T>, override val setter: (prefs: Pref, value: List<T>) -> Pref, @param:StringRes val summary: Int? = null, - val toSharedPrefs: (T) -> String, - val fromSharedPrefs: (String) -> T?, ) : AppPreference<Pref, List<T>> data class AppClickablePreference<Pref>( diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index f84744c7..bf8ab2a4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.google.protobuf.InvalidProtocolBufferException +import org.jellyfin.sdk.model.api.BaseItemKind import java.io.InputStream import java.io.OutputStream import javax.inject.Inject @@ -82,6 +83,7 @@ class AppPreferencesSerializer maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt() enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue combineContinueNext = AppPreference.CombineContinueNext.defaultValue + maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() }.build() interfacePreferences = InterfacePreferences @@ -100,6 +102,12 @@ class AppPreferencesSerializer .apply { resetSubtitles() }.build() + hdrSubtitlesPreferences = + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + }.build() liveTvPreferences = LiveTvPreferences @@ -113,6 +121,20 @@ class AppPreferencesSerializer colorCodePrograms = AppPreference.LiveTvColorCodePrograms.defaultValue }.build() + + screensaverPreference = + ScreensaverPreferences + .newBuilder() + .apply { + startDelay = ScreensaverPreference.DEFAULT_START_DELAY + duration = ScreensaverPreference.DEFAULT_DURATION + animate = ScreensaverPreference.Animate.defaultValue + maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE + showClock = ScreensaverPreference.ShowClock.defaultValue + clearItemTypes() + addItemTypes(BaseItemKind.MOVIE.serialName) + addItemTypes(BaseItemKind.SERIES.serialName) + }.build() }.build() advancedPreferences = @@ -122,6 +144,14 @@ class AppPreferencesSerializer imageDiskCacheSizeBytes = AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT }.build() + + photoPreferences = + PhotoPreferences + .newBuilder() + .apply { + slideshowDuration = AppPreference.SlideshowDuration.defaultValue + slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue + }.build() }.build() override suspend fun readFrom(input: InputStream): AppPreferences { @@ -180,6 +210,16 @@ inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.B advancedPreferences = advancedPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder.() -> Unit): AppPreferences = + update { + photoPreferences = photoPreferences.toBuilder().apply(block).build() + } + +inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences = + updateInterfacePreferences { + screensaverPreference = screensaverPreference.toBuilder().apply(block).build() + } + fun SubtitlePreferences.Builder.resetSubtitles() { fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() @@ -193,4 +233,5 @@ fun SubtitlePreferences.Builder.resetSubtitles() { backgroundStyle = SubtitleSettings.BackgroundStylePref.defaultValue margin = SubtitleSettings.Margin.defaultValue.toInt() edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() + imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt new file mode 100644 index 00000000..1e1ad91c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt @@ -0,0 +1,183 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication +import org.jellyfin.sdk.model.api.BaseItemKind +import kotlin.time.Duration.Companion.milliseconds + +object ScreensaverPreference { + val Enabled = + AppSwitchPreference<AppPreferences>( + title = R.string.in_app_screensaver, + defaultValue = false, + getter = { it.interfacePreferences.screensaverPreference.enabled }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { enabled = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + const val DEFAULT_START_DELAY = 15 * 60_000L + private val startDelayValues = + listOf( + 30_000L, + 60_000L, + 2 * 60_000L, + 5 * 60_000L, + 10 * 60_000L, + 15 * 60_000L, + 30 * 60_000L, + 60 * 60_000L, + ) + val StartDelay = + AppSliderPreference<AppPreferences>( + title = R.string.start_after, + defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(), + min = 0, + max = startDelayValues.size - 1L, + interval = 1, + getter = { + startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + startDelay = startDelayValues[value.toInt()] + } + }, + summarizer = { value -> + if (value != null) { + val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY + v.milliseconds.toString() + } else { + null + } + }, + ) + + const val DEFAULT_DURATION = 30_000L + private val durationValues = + listOf( + 15_000L, + 30_000L, + 60_000L, + 2 * 60_000L, + ) + val Duration = + AppSliderPreference<AppPreferences>( + title = R.string.duration, + defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(), + min = 0, + max = durationValues.size - 1L, + interval = 1, + getter = { + durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + duration = durationValues[value.toInt()] + } + }, + summarizer = { value -> + if (value != null) { + val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION + v.milliseconds.toString() + } else { + null + } + }, + ) + + val ShowClock = + AppSwitchPreference<AppPreferences>( + title = R.string.show_clock, + defaultValue = AppPreference.ShowClock.defaultValue, + getter = { it.interfacePreferences.screensaverPreference.showClock }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { showClock = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + val Animate = + AppSwitchPreference<AppPreferences>( + title = R.string.animate, + defaultValue = true, + getter = { it.interfacePreferences.screensaverPreference.animate }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { animate = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + const val DEFAULT_MAX_AGE = 16 + private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1) + val MaxAge = + AppSliderPreference<AppPreferences>( + title = R.string.max_age_rating, + defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(), + min = 0, + max = maxAgeValues.size - 1L, + interval = 1, + getter = { + it.interfacePreferences.screensaverPreference.maxAgeFilter + .takeIf { it >= 0 } + ?.let { maxAgeValues.indexOf(it).toLong() } + ?: maxAgeValues.lastIndex.toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + maxAgeFilter = maxAgeValues[value.toInt()] + } + }, + summarizer = { value -> + when (value) { + null -> { + null + } + + maxAgeValues.lastIndex.toLong() -> { + WholphinApplication.instance.getString(R.string.no_max) + } + + 0L -> { + WholphinApplication.instance.getString(R.string.for_all_ages) + } + + else -> { + WholphinApplication.instance.getString( + R.string.up_to_age, + maxAgeValues[value.toInt()].toString(), + ) + } + } + }, + ) + + val ItemTypes = + AppMultiChoicePreference<AppPreferences, BaseItemKind>( + title = R.string.include_types, + summary = R.string.include_types_summary, + defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO), + displayValues = R.array.screensaver_item_types, + getter = { + it.interfacePreferences.screensaverPreference.itemTypesList.map { type -> + BaseItemKind.fromName(type) + } + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + clearItemTypes() + addAllItemTypes(value.map { it.serialName }) + } + }, + ) + + val Start = + AppClickablePreference<AppPreferences>( + title = R.string.start_screensaver, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index d6fbea40..23d6cef6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -8,17 +8,22 @@ import androidx.preference.PreferenceManager import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences +import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions +import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides +import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber import java.io.File import javax.inject.Inject @@ -213,4 +218,49 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + subtitlesPreferences = + subtitlesPreferences + .toBuilder() + .apply { + imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt() + }.build() + // Copy current subtitle prefs as HDR ones + hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build() + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) { + appPreferences.updateData { + it.updatePhotoPreferences { + slideshowDuration = AppPreference.SlideshowDuration.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) { + appPreferences.updateData { + it.updateHomePagePreferences { + maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.5.0-6-g0"))) { + appPreferences.updateData { + it.updateScreensaverPreferences { + startDelay = ScreensaverPreference.DEFAULT_START_DELAY + duration = ScreensaverPreference.DEFAULT_DURATION + animate = ScreensaverPreference.Animate.defaultValue + maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE + clearItemTypes() + addItemTypes(BaseItemKind.MOVIE.serialName) + addItemTypes(BaseItemKind.SERIES.serialName) + } + } + } } 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..e0c48cca 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<HomeRowConfig>(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<Library>, + 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<HomeRowConfigDisplay>, +) { + 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..1dd642b0 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 @@ -28,15 +28,16 @@ class ImageUrlService itemType: BaseItemKind, seriesId: UUID?, useSeriesForPrimary: Boolean, - imageTags: Map<ImageType, String?>, imageType: ImageType, + imageTags: Map<ImageType, String?>, + backdropTags: List<String>, + parentThumbId: UUID? = null, + parentBackdropId: UUID? = null, fillWidth: Int? = null, fillHeight: Int? = null, ): String? = when (imageType) { - ImageType.BACKDROP, - ImageType.LOGO, - -> { + ImageType.LOGO -> { if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { getItemImageUrl( itemId = seriesId, @@ -54,8 +55,86 @@ class ImageUrlService } } + ImageType.BACKDROP, + -> { + if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { + getItemImageUrl( + itemId = seriesId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (backdropTags.isNotEmpty()) { + getItemImageUrl( + itemId = itemId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + null + } + } + + 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 +178,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 44ed1a58..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<BaseItem> { val request = GetResumeItemsRequest( @@ -60,13 +61,19 @@ class LatestNextUpService remove(BaseItemKind.EPISODE) } }, + enableImageTypes = + listOf( + ImageType.PRIMARY, + ImageType.THUMB, + ImageType.BACKDROP, + ), ) val items = api.itemsApi .getResumeItems(request) .content .items - .map { BaseItem.from(it, api, true) } + .map { BaseItem.from(it, api, useSeriesForPrimary) } return items } @@ -75,7 +82,11 @@ class LatestNextUpService limit: Int, enableRewatching: Boolean, enableResumable: Boolean, + maxDays: Int, + useSeriesForPrimary: Boolean = true, ): List<BaseItem> { + val nextUpDateCutoff = + maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } val request = GetNextUpRequest( userId = userId, @@ -86,13 +97,14 @@ class LatestNextUpService enableResumable = enableResumable, enableUserData = true, enableRewatching = enableRewatching, + nextUpDateCutoff = nextUpDateCutoff, ) val nextUp = api.tvShowsApi .getNextUp(request) .content .items - .map { BaseItem.from(it, api, true) } + .map { BaseItem.from(it, api, useSeriesForPrimary) } return nextUp } @@ -188,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/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt new file mode 100644 index 00000000..a13881a9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -0,0 +1,110 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +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.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.showToast +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MediaManagementService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + ) { + private val _deletedItemFlow = + MutableSharedFlow<DeletedItem>( + replay = 1, + extraBufferCapacity = 0, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** + * Listen for recently deleted items. Useful for ViewModels to react and refresh data + */ + val deletedItemFlow: SharedFlow<DeletedItem> = _deletedItemFlow + + suspend fun canDelete(item: BaseItem): Boolean { + val appPreferences = userPreferencesService.getCurrent().appPreferences + return canDelete(item, appPreferences) + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean { + Timber.v("canDelete %s: %s", item.id, item.canDelete) + val enabled = appPreferences.interfacePreferences.enableMediaManagement + return enabled && + item.canDelete && + if (item.type == BaseItemKind.RECORDING) { + serverRepository.currentUserDto.value + ?.policy + ?.enableLiveTvManagement == true + } else { + true + } + } + + suspend fun deleteItem(item: BaseItem): DeleteResult { + try { + Timber.i("Deleting %s", item.id) + // TODO enable + api.libraryApi.deleteItem(item.id) + _deletedItemFlow.emit(DeletedItem(item)) + return DeleteResult.Success + } catch (ex: Exception) { + Timber.e(ex, "Error deleting %s", item.id) + return DeleteResult.Error(ex) + } + } + } + +data class DeletedItem( + val item: BaseItem, +) + +sealed interface DeleteResult { + data object Success : DeleteResult + + data class Error( + val ex: Exception, + ) : DeleteResult +} + +fun ViewModel.deleteItem( + context: Context, + mediaManagementService: MediaManagementService, + item: BaseItem, + onSuccess: () -> Unit = {}, +) = viewModelScope.launchIO { + when (val r = mediaManagementService.deleteItem(item)) { + is DeleteResult.Error -> { + showToast( + context, + "Error deleting item: ${r.ex.localizedMessage}", + ) + } + + DeleteResult.Success -> { + showToast(context, "Deleted") + onSuccess.invoke() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt new file mode 100644 index 00000000..14a93ba2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt @@ -0,0 +1,80 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.os.Build +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.ExceptionHandler +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.clientLogApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MediaReportService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + private val clientInfo: ClientInfo, + private val deviceInfo: DeviceInfo, + private val deviceProfileService: DeviceProfileService, + @param:IoCoroutineScope private val ioScope: CoroutineScope, + ) { + val json = + Json { + encodeDefaults = false + } + + fun sendReportFor(itemId: UUID) { + ioScope.launchIO(ExceptionHandler(autoToast = true)) { + val item = api.userLibraryApi.getItem(itemId = itemId).content + sendReportFor(item) + } + } + + suspend fun sendReportFor(item: BaseItemDto) { + val sources = + item.mediaSources ?: api.userLibraryApi + .getItem(itemId = item.id) + .content.mediaSources + val sourcesJson = json.encodeToString(sources) + val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences + val serverVersion = serverRepository.currentServer.value?.serverVersion + val deviceProfile = + deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion) + val deviceProfileJson = json.encodeToString(deviceProfile) + val body = + """ + Send media info + serverVersion=$serverVersion + clientInfo=$clientInfo + deviceInfo=$deviceInfo + manufacturer=${Build.MANUFACTURER} + model=${Build.MODEL} + apiLevel=${Build.VERSION.SDK_INT} + + playbackPrefs=${playbackPrefs.toString().replace("\n", ", ").replace("\t", " ")} + + deviceProfile=$deviceProfileJson + + mediaSources=$sourcesJson + """.trimIndent() + Timber.w(body) + val response by api.clientLogApi.logFile(body) + showToast(context, "Sent! Filename=${response.fileName}") + } + } 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..b83f0bd0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -0,0 +1,194 @@ +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.ui.showToast +import com.github.damontecres.wholphin.util.supportedCollectionTypes +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.UserDto +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NavDrawerService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:DefaultCoroutineScope private val coroutineScope: CoroutineScope, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, + ) { + private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) + val state: StateFlow<NavDrawerItemState> = _state + + init { + serverRepository.currentUser + .asFlow() + .combine(serverRepository.currentUserDto.asFlow()) { user, userDto -> + Pair(user, userDto) + }.onEach { (user, userDto) -> + Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id) + _state.update { + it.copy( + items = emptyList(), + moreItems = emptyList(), + ) + } + if (user != null && userDto != null && user.id == userDto.id) { + updateNavDrawer(user, userDto) + } + }.catch { ex -> + Timber.e(ex, "Error updating nav drawer") + showToast(context, "Error fetching user's views") + }.launchIn(coroutineScope) + + seerrServerRepository.active + .onEach { discoverActive -> + _state.update { it.copy(discoverEnabled = discoverActive) } + }.launchIn(coroutineScope) + } + + suspend fun getAllUserLibraries( + userId: UUID, + tvAccess: Boolean, + ): List<Library> { + val userViews = + api.userViewsApi + .getUserViews(userId = userId) + .content.items + val recordingFolders = + if (tvAccess) { + api.liveTvApi + .getRecordingFolders(userId = userId) + .content.items + .map { it.id } + .toSet() + } else { + setOf() + } + val libraries = + userViews + .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } + .map { + Library( + itemId = it.id, + name = it.name ?: "", + type = it.type, + collectionType = it.collectionType ?: CollectionType.UNKNOWN, + isRecordingFolder = it.id in recordingFolders, + ) + } + return libraries + } + + suspend fun getFilteredUserLibraries( + user: JellyfinUser, + tvAccess: Boolean, + ): List<Library> { + val pins = + serverPreferencesDao + .getNavDrawerPinnedItems(user) + .associateBy { it.itemId } + val libraries = + getAllUserLibraries(user.id, tvAccess) + .filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED } + return libraries + } + + suspend fun updateNavDrawer( + user: JellyfinUser, + userDto: UserDto, + ) { + val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) + val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess) + val libraries = + allLibraries + .map { + val destination = + if (it.isRecordingFolder) { + Destination.Recordings(it.itemId) + } else { + Destination.MediaItem( + it.itemId, + it.type, + it.collectionType, + ) + } + ServerNavDrawerItem( + itemId = it.itemId, + name = it.name, + destination = destination, + type = it.collectionType, + ) + } + val allItems = builtins + libraries + + val navDrawerPins = + withContext(Dispatchers.IO) { + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + } + + val items = mutableListOf<NavDrawerItem>() + val moreItems = mutableListOf<NavDrawerItem>() + allItems + // Sort by order if non-default, existing items before customize will have -1 value + // New items from the server will get Int.MAX_VALUE + // Items the user doesn't have access to anymore will be skipped + .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE } + .forEach { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + if (pinned == NavPinType.PINNED) { + items.add(it) + } else { + moreItems.add(it) + } + } + + _state.update { + it.copy( + items = items, + moreItems = moreItems, + ) + } + } + } + +data class NavDrawerItemState( + val items: List<NavDrawerItem>, + val moreItems: List<NavDrawerItem>, + val discoverEnabled: Boolean, +) { + companion object { + val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false) + } +} + +val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt index b4967b03..6a35dda5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.services +import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.letNotEmpty +import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import javax.inject.Inject @@ -12,6 +14,7 @@ import javax.inject.Singleton class PeopleFavorites @Inject constructor( + @param:ApplicationContext private val context: Context, private val api: ApiClient, ) { suspend fun getPeopleFor(item: BaseItem): List<Person> = @@ -27,7 +30,14 @@ class PeopleFavorites val people = item.data.people ?.letNotEmpty { people -> - people.map { Person.fromDto(it, favorites[it.id] ?: false, api) } + people.map { + Person.fromDto( + context, + it, + favorites[it.id] ?: false, + api, + ) + } }.orEmpty() people } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index 3c32a497..4be082a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -21,7 +21,10 @@ class PlaybackLifecycleObserver override fun onStart(owner: LifecycleOwner) { val lastDest = navigationManager.backStack.lastOrNull() - if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) { + if (lastDest is Destination.Playback || + lastDest is Destination.PlaybackList || + lastDest is Destination.Slideshow + ) { navigationManager.goBack() } wasPlaying = null 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 be6e461e..06d116a4 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 @@ -6,17 +6,17 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.view.Display -import androidx.lifecycle.LiveData -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import timber.log.Timber -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton import kotlin.math.roundToInt @@ -28,25 +28,6 @@ class RefreshRateService constructor( @param:ApplicationContext private val context: Context, ) { - private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) - private val originalMode = display.mode - - val supportedDisplayModes get() = display.supportedModes.orEmpty() - - private val displayModes: List<DisplayMode> by lazy { - display.supportedModes - .orEmpty() - .map { DisplayMode(it) } - .sortedWith( - compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight }) - .thenBy { it.refreshRateRounded }, - ) - } - - private val _refreshRateMode = EqualityMutableLiveData<Int>(originalMode.modeId) - val refreshRateMode: LiveData<Int> = _refreshRateMode - /** * Find the best display mode for the given stream and signal to change to it */ @@ -54,11 +35,23 @@ class RefreshRateService stream: MediaStream, switchRefreshRate: Boolean, switchResolution: Boolean, - ) { + ) = withContext(Dispatchers.IO) { if (!switchRefreshRate && !switchResolution) { Timber.v("Not switching either refresh rate nor resolution") - return + return@withContext } + val displayManager = + MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + val displayModes = + display.supportedModes + .orEmpty() + .map { DisplayMode(it) } + .sortedWith( + compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) + val currentDisplayMode = display.mode require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } val width = stream.width @@ -67,7 +60,7 @@ class RefreshRateService if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate if (width == null || height == null || frameRate == null) { Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate) - return + return@withContext } Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate) val targetMode = @@ -86,14 +79,20 @@ class RefreshRateService listener, Handler(Looper.myLooper() ?: Looper.getMainLooper()), ) - _refreshRateMode.setValueOnMain(targetMode.modeId) try { - if (!listener.latch.await(5, TimeUnit.SECONDS)) { + MainActivity.instance.changeDisplayMode(targetMode.modeId) + val result = + withTimeoutOrNull(5.seconds) { + listener.deferred.await() + } + if (result == null) { Timber.w("Timed out waiting for display change") showToast(context, "Refresh rate switch is taking a long time") } - } catch (ex: InterruptedException) { + } catch (ex: Exception) { Timber.w(ex, "Exception waiting for refresh rate switch") + } finally { + displayManager.unregisterDisplayListener(listener) } val targetRate = (targetMode.refreshRate * 1000).roundToInt() val isSeamless = @@ -110,7 +109,6 @@ class RefreshRateService // Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate) delay(2.seconds) } - displayManager.unregisterDisplayListener(listener) } } @@ -118,13 +116,13 @@ class RefreshRateService * Reset the display mode to the original */ fun resetRefreshRate() { - _refreshRateMode.value = originalMode.modeId + MainActivity.instance.changeDisplayMode(0) } private class Listener( val displayId: Int, ) : DisplayManager.DisplayListener { - val latch = CountDownLatch(1) + val deferred = CompletableDeferred<Unit>() override fun onDisplayAdded(displayId: Int) { } @@ -132,7 +130,7 @@ class RefreshRateService override fun onDisplayChanged(displayId: Int) { if (displayId == this.displayId) { Timber.v("Got display change for $displayId") - latch.countDown() + deferred.complete(Unit) } } @@ -160,33 +158,58 @@ class RefreshRateService if (refreshRateSwitch) { displayModes .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } - .filter { - it.refreshRateRounded % streamRate == 0 || // Exact multiple - it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps - } + .filter { frameRateMatches(it.refreshRateRounded, streamRate) } } else { displayModes .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } } // Timber.v("display modes candidates: %s", candidates.joinToString("\n")) return if (!resolutionSwitch) { - candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } + candidates + .filter { it.refreshRateRounded == streamRate } + .maxByOrNull { it.physicalWidth * it.physicalHeight } + ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } } else { + // Exact resolution & frame rate candidates.firstOrNull { it.physicalWidth == streamWidth && it.physicalHeight == streamHeight && it.refreshRateRounded == streamRate } - ?: candidates.firstOrNull { - it.physicalWidth == streamWidth && + // Next highest resolution, exact frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && it.physicalHeight >= streamHeight && it.refreshRateRounded == streamRate } + // Exact resolution, acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth == streamWidth && + it.physicalHeight == streamHeight && + frameRateMatches(it.refreshRateRounded, streamRate) + } + // Next highest resolution, acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && + it.physicalHeight >= streamHeight && + frameRateMatches(it.refreshRateRounded, streamRate) + } + // Fall back to largest resolution, exact frame rate ?: candidates .filter { it.refreshRateRounded == streamRate } .maxByOrNull { it.physicalWidth * it.physicalHeight } + // Finally, just the highest resolution + ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } } } + + fun frameRateMatches( + refreshRateRounded: Int, + streamRate: Int, + ): Boolean { + return refreshRateRounded % streamRate == 0 || // Exact multiple + refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt new file mode 100644 index 00000000..5d73ec21 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -0,0 +1,218 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import coil3.imageLoader +import coil3.request.ImageRequest +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.components.CurrentItem +import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +@Singleton +class ScreensaverService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:DefaultCoroutineScope private val scope: CoroutineScope, + private val api: ApiClient, + private val userPreferencesService: UserPreferencesService, + private val imageUrlService: ImageUrlService, + ) { + private val _state = MutableStateFlow(ScreensaverState(false, false, false, false)) + val state: StateFlow<ScreensaverState> = _state + + val keepScreenOn = MutableStateFlow(false) + + private var waitJob: Job? = null + + init { + userPreferencesService.flow + .onEach { prefs -> + _state.update { + val enabled = + prefs.appPreferences.interfacePreferences.screensaverPreference.enabled + keepScreenOnInternal(enabled) + ScreensaverState(enabled, false, false, false) + } + }.launchIn(scope) + } + + fun pulse() { + waitJob?.cancel() + if (_state.value.enabled) { +// Timber.v("pulse") + _state.update { + if (!it.active) { + it.copy(active = false) + } else { + it + } + } + + if (!_state.value.paused) { + waitJob = + scope.launch(ExceptionHandler()) { + val startDelay = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds + delay(startDelay) + _state.update { + it.copy(active = true) + } + } + } + } + } + + fun start() { + _state.update { + it.copy( + enabledTemp = true, + active = true, + ) + } + } + + fun stop(cancelJob: Boolean) { + _state.update { + it.copy( + enabledTemp = false, + active = false, + ) + } + if (cancelJob) waitJob?.cancel() + } + + fun keepScreenOn(keep: Boolean) { + scope.launchDefault { + val screensaverEnabled = _state.value.enabled + Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled) + if (screensaverEnabled) { + // Page is requesting to keep screen on, so we don't wait to show the screensaver + _state.update { + it.copy(active = false, paused = keep) + } + if (!keep) { + pulse() + } + } else { + keepScreenOnInternal(keep) + } + } + } + + private fun keepScreenOnInternal(keep: Boolean) { + keepScreenOn.update { keep } + } + + fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> = + flow { + val prefs = + userPreferencesService.flow + .first() + .appPreferences + .interfacePreferences.screensaverPreference + val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 } + val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) } + val request = + GetItemsRequest( + recursive = true, + includeItemTypes = itemTypes, + imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP), + sortBy = listOf(ItemSortBy.RANDOM), + maxOfficialRating = maxAge?.toString(), + hasParentalRating = maxAge?.let { true }, + ) + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() + Timber.v("Got %s items", pager.size) + var index = 0 + if (pager.isEmpty()) { + emit(null) + } else { + while (true) { + val item = pager.getBlocking(index) + Timber.v("Next index=%s, item=%s", index, item?.id) + if (item != null) { + val backdropUrl = + if (item.type == BaseItemKind.PHOTO) { + api.libraryApi.getDownloadUrl(item.id) + } else { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + } + val title = + if (item.type == BaseItemKind.PHOTO) { + item.data.premiereDate?.let { + formatDate(it.toLocalDate()) + } + } else { + item.title + } + val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) + if (backdropUrl != null) { + try { + context.imageLoader + .enqueue( + ImageRequest + .Builder(context) + .data(backdropUrl) + .build(), + ).job + .await() + emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) + } catch (_: CancellationException) { + break + } + val duration = + userPreferencesService + .getCurrent() + .appPreferences + .interfacePreferences.screensaverPreference.duration.milliseconds + delay(duration) + } + } + index++ + } + } + }.flowOn(Dispatchers.Default).cancellable() + } + +data class ScreensaverState( + val enabled: Boolean, + val enabledTemp: Boolean, + val active: Boolean, + val paused: Boolean, +) { + val show get() = (enabled || enabledTemp) && active && !paused +} 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..970e80c3 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 @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.api.seerr.model.PublicSettings import com.github.damontecres.wholphin.api.seerr.model.User import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.SeerrServer @@ -24,8 +25,10 @@ import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.supervisorScope import okhttp3.OkHttpClient import timber.log.Timber import javax.inject.Inject @@ -44,18 +47,33 @@ class SeerrServerRepository private val serverRepository: ServerRepository, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { - private val _current = MutableStateFlow<CurrentSeerr?>(null) - val current: StateFlow<CurrentSeerr?> = _current - val currentServer: Flow<SeerrServer?> = current.map { it?.server } - val currentUser: Flow<SeerrUser?> = current.map { it?.user } + private val _connection = + MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured) + val connection: StateFlow<SeerrConnectionStatus> = _connection + + val current: Flow<CurrentSeerr?> = + _connection.map { (it as? SeerrConnectionStatus.Success)?.current } + val currentServer: Flow<SeerrServer?> = + connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } + val currentUser: Flow<SeerrUser?> = + connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } /** * Whether Seerr integration is currently active of not */ - val active: Flow<Boolean> = current.map { it != null && seerrApi.active } + val active: Flow<Boolean> = + connection.map { it is SeerrConnectionStatus.Success && seerrApi.active } fun clear() { - _current.update { null } + _connection.update { SeerrConnectionStatus.NotConfigured } + seerrApi.update("", null) + } + + fun error( + serverUrl: String, + exception: Exception, + ) { + _connection.update { SeerrConnectionStatus.Error(serverUrl, exception) } seerrApi.update("", null) } @@ -65,8 +83,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), + ) } } @@ -145,7 +165,7 @@ class SeerrServerRepository apiKey, okHttpClient .newBuilder() - .connectTimeout(5.seconds) + .connectTimeout(2.seconds) .readTimeout(6.seconds) .build(), ) @@ -153,10 +173,11 @@ class SeerrServerRepository return LoadingState.Success } - suspend fun removeServer() { - val current = _current.value ?: return - seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) + suspend fun removeServerForCurrentUser(): Boolean { + val current = current.firstOrNull() ?: return false + val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) clear() + return rows > 0 } } @@ -165,6 +186,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 +260,7 @@ class UserSwitchListener private val seerrServerRepository: SeerrServerRepository, private val seerrServerDao: SeerrServerDao, private val seerrApi: SeerrApi, + private val homeSettingsService: HomeSettingsService, ) { init { context as AppCompatActivity @@ -233,43 +268,58 @@ 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 - } - } - seerrServerRepository.set(server, seerrUser, userConfig) - } - } + switchUser(user) } } } } + + private suspend fun switchUser(user: JellyfinUser) = + supervisorScope { + // 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) + } + } + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt index bd95ea1d..e4910031 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt @@ -17,7 +17,7 @@ import javax.inject.Singleton class SetupNavigationManager @Inject constructor() { - var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading) + var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading) /** * Go to the specified [SetupDestination] 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 new file mode 100644 index 00000000..0fe32179 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -0,0 +1,100 @@ +package com.github.damontecres.wholphin.services + +import androidx.lifecycle.asFlow +import androidx.work.WorkInfo +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 +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +sealed interface SuggestionsResource { + data object Loading : SuggestionsResource + + data class Success( + val items: List<BaseItem>, + ) : SuggestionsResource + + data object Empty : SuggestionsResource +} + +@Singleton +class SuggestionService + @Inject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val cache: SuggestionsCache, + private val workManager: WorkManager, + ) { + @OptIn(ExperimentalCoroutinesApi::class) + fun getSuggestionsFlow( + parentId: UUID, + itemKind: BaseItemKind, + ): Flow<SuggestionsResource> { + return serverRepository.currentUser + .asFlow() + .flatMapLatest { user -> + val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty) + val cachedSuggestions = cache.get(userId, parentId, itemKind) + if (cachedSuggestions == null) { + workManager + .getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) + .map { workInfos -> + val isActive = + workInfos.any { + it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED + } + 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) + } + } + } + } + } + + private suspend fun fetchItemsByIds( + ids: List<UUID>, + itemKind: BaseItemKind, + ): List<BaseItem> { + val isSeries = itemKind == BaseItemKind.SERIES + val request = + GetItemsRequest( + ids = ids, + fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW), + ) + return GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem.from(it, api, isSeries) } + } + } 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 new file mode 100644 index 00000000..0921a1be --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -0,0 +1,108 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.ui.toServerString +import com.mayakapps.kache.InMemoryKache +import com.mayakapps.kache.ObjectKache +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import timber.log.Timber +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Serializable +data class CachedSuggestions( + val ids: List<UUID>, +) + +@Singleton +class SuggestionsCache + @Inject + constructor( + @param:ApplicationContext private val context: Context, + ) { + private val cacheDir: File + get() = File(context.cacheDir, "suggestions") + private val json = Json { ignoreUnknownKeys = true } + private val mutex = Mutex() + + private val memoryCache = + InMemoryKache<String, CachedSuggestions>(maxSize = 8) { + creationScope = CoroutineScope(Dispatchers.IO) + } + + private fun cacheKey( + userId: UUID, + libraryId: UUID, + itemKind: BaseItemKind, + ) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}" + + @OptIn(ExperimentalSerializationApi::class) + suspend fun get( + userId: UUID, + libraryId: UUID, + itemKind: BaseItemKind, + ): CachedSuggestions? { + val key = cacheKey(userId, libraryId, itemKind) + return memoryCache.getOrPut(key) { + mutex.withLock { + try { + val cacheFile = File(cacheDir, "$key.json") + if (!cacheFile.exists()) { + return@withLock null + } + + cacheFile.inputStream().use { + json.decodeFromStream<CachedSuggestions>(it) + } + } catch (ex: Exception) { + Timber.e(ex, "Exception reading from disk cache") + null + } + } + } + } + + @OptIn(ExperimentalSerializationApi::class) + suspend fun put( + userId: UUID, + libraryId: UUID, + itemKind: BaseItemKind, + ids: List<UUID>, + ) { + val key = cacheKey(userId, libraryId, itemKind) + val suggestions = CachedSuggestions(ids) + memoryCache.put(key, suggestions) + try { + cacheDir.mkdirs() + mutex.withLock { + File(cacheDir, "$key.json").outputStream().use { + json.encodeToStream(suggestions, it) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Exception writing to disk cache") + } + } + } + +fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L + +fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty() + +suspend fun <T : Any> ObjectKache<T, *>.containsKey(key: T): Boolean = get(key) != null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt new file mode 100644 index 00000000..86bab2f3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -0,0 +1,122 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.util.ExceptionHandler +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import kotlin.random.Random +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration + +@ActivityScoped +class SuggestionsSchedulerService + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + private val workManager: WorkManager, + ) { + private val activity = + (context as? AppCompatActivity) + ?: throw IllegalStateException( + "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", + ) + + // Exposed for testing + internal var dispatcher: CoroutineDispatcher = Dispatchers.IO + internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) } + + init { + serverRepository.current.observe(activity) { user -> + Timber.v("New user %s", user?.user?.id) + if (user == null) { + workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) + } else { + activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { + scheduleWork(user.user.id, user.server.id) + } + } + } + } + + private suspend fun scheduleWork( + userId: UUID, + serverId: UUID, + ) { + val workInfos = + withContext(dispatcher) { + workManager + .getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) + .get() + } + val activeWork = + workInfos.firstOrNull { + !it.state.isFinished + } + val scheduledUserId = + activeWork + ?.tags + ?.firstOrNull { it.startsWith("user:") } + ?.removePrefix("user:") + + val isAlreadyScheduledForUser = scheduledUserId == userId.toString() + if (isAlreadyScheduledForUser) { + Timber.d("SuggestionsWorker already scheduled for user %s", userId) + return + } + + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val inputData = + workDataOf( + SuggestionsWorker.PARAM_USER_ID to userId.toString(), + SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(), + ) + + val periodicWorkRequestBuilder = + PeriodicWorkRequestBuilder<SuggestionsWorker>( + repeatInterval = 12.hours.toJavaDuration(), + flexTimeInterval = 1.hours.toJavaDuration(), + ).setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15.minutes.toJavaDuration(), + ).setInputData(inputData) + .addTag("user:$userId") + + val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L) + periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration()) + + Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay") + + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = SuggestionsWorker.WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = periodicWorkRequestBuilder.build(), + ) + } + } 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 new file mode 100644 index 00000000..4560f2d9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -0,0 +1,281 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.UUID +import kotlin.coroutines.CoroutineContext + +private val BaseItemDto.relevantId: UUID get() = seriesId ?: id + +@HiltWorker +class SuggestionsWorker + @AssistedInject + constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val serverRepository: ServerRepository, + private val preferences: DataStore<AppPreferences>, + private val api: ApiClient, + private val cache: SuggestionsCache, + ) : CoroutineWorker(context, workerParams) { + override suspend fun doWork(): Result { + Timber.d("Start") + val serverId = inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() + val userId = inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() + + if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { + var currentUser = serverRepository.current.value + if (currentUser == null) { + serverRepository.restoreSession(serverId, userId) + currentUser = serverRepository.current.value + } + if (currentUser == null) { + Timber.w("No user found during run") + return Result.failure() + } + } + + try { + val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + val itemsPerRow = + prefs.homePagePreferences.maxItemsPerRow + .takeIf { it > 0 } + ?: AppPreference.HomePageItems.defaultValue.toInt() + + val views = + api.userViewsApi + .getUserViews(userId = userId) + .content.items + .orEmpty() + if (views.isEmpty()) { + return Result.success() + } + val results = + supervisorScope { + val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions") + views + .mapNotNull { view -> + val itemKind = + getTypeForCollection(view.collectionType) + ?: return@mapNotNull null + async(Dispatchers.IO) { + runCatching { + Timber.v("Fetching suggestions for view %s", view.id) + val suggestions = + fetchSuggestions( + context, + view.id, + userId, + itemKind, + itemsPerRow, + ) + ensureActive() + val newIds = suggestions.map { it.id } + val cachedIds = cache.get(userId, view.id, itemKind)?.ids + if (cachedIds == newIds) { + Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id) + return@runCatching + } + cache.put( + userId, + view.id, + itemKind, + newIds, + ) + }.onFailure { e -> + Timber.e( + e, + "Failed to fetch suggestions for view %s", + view.id, + ) + } + } + }.awaitAll() + } + val successCount = results.count { it.isSuccess } + val failureCount = results.count { it.isFailure } + if (failureCount > 0 && successCount == 0) { + Timber.w("All attempts failed ($failureCount views), scheduling retry") + return Result.retry() + } + Timber.d("Completed with $successCount successes and $failureCount failures") + return Result.success() + } catch (ex: ApiClientException) { + Timber.w(ex, "SuggestionsWorker ApiClientException, will retry") + return Result.retry() + } catch (e: Exception) { + Timber.e(e, "SuggestionsWorker failed") + return Result.failure() + } + } + + private suspend fun fetchSuggestions( + coroutineContext: CoroutineContext, + parentId: UUID, + userId: UUID, + itemKind: BaseItemKind, + itemsPerRow: Int, + ): List<BaseItemDto> = + coroutineScope { + val isSeries = itemKind == BaseItemKind.SERIES + val historyItemType = if (isSeries) BaseItemKind.EPISODE else itemKind + + val contextualLimit = (itemsPerRow * 0.4).toInt().coerceAtLeast(1) + val randomLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1) + val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1) + + val historyDeferred = + async(coroutineContext) { + fetchItems( + parentId = parentId, + userId = userId, + itemKind = historyItemType, + sortBy = ItemSortBy.DATE_PLAYED, + isPlayed = true, + limit = 20, + extraFields = listOf(ItemFields.GENRES), + ).distinctBy { it.relevantId }.take(3) + } + + val seedItems = historyDeferred.await() + + val allGenreIds = + seedItems + .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } + .distinct() + + val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId } + + val contextualDeferred = + async(coroutineContext) { + if (allGenreIds.isEmpty()) { + emptyList() + } else { + fetchItems( + parentId = parentId, + userId = userId, + itemKind = itemKind, + sortBy = ItemSortBy.RANDOM, + isPlayed = false, + limit = contextualLimit, + genreIds = allGenreIds, + excludeItemIds = excludeIds.toList(), + ) + } + } + + val randomDeferred = + async(coroutineContext) { + fetchItems( + parentId = parentId, + userId = userId, + itemKind = itemKind, + sortBy = ItemSortBy.RANDOM, + isPlayed = false, + limit = randomLimit, + ) + } + + val freshDeferred = + async(coroutineContext) { + fetchItems( + parentId = parentId, + userId = userId, + itemKind = itemKind, + sortBy = ItemSortBy.DATE_CREATED, + sortOrder = SortOrder.DESCENDING, + isPlayed = false, + limit = freshLimit, + ) + } + withContext(Dispatchers.Default) { + val contextual = contextualDeferred.await() + val random = randomDeferred.await() + val fresh = freshDeferred.await() + + (contextual + fresh + random) + .asSequence() + .distinctBy { it.id } + .filterNot { excludeIds.contains(it.relevantId) } + .toList() + .shuffled() + .take(itemsPerRow) + } + } + + private suspend fun fetchItems( + parentId: UUID, + userId: UUID, + itemKind: BaseItemKind, + sortBy: ItemSortBy, + isPlayed: Boolean, + limit: Int, + sortOrder: SortOrder? = null, + genreIds: List<UUID>? = null, + excludeItemIds: List<UUID>? = null, + extraFields: List<ItemFields> = emptyList(), + ): List<BaseItemDto> { + val request = + GetItemsRequest( + parentId = parentId, + userId = userId, + fields = extraFields, + includeItemTypes = listOf(itemKind), + genreIds = genreIds, + recursive = true, + isPlayed = isPlayed, + excludeItemIds = excludeItemIds, + sortBy = listOf(sortBy), + sortOrder = sortOrder?.let { listOf(it) }, + limit = limit, + enableTotalRecordCount = false, + imageTypeLimit = 0, + ) + return GetItemsRequestHandler + .execute(api, request) + .content.items + .orEmpty() + } + + companion object { + 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/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt index 90d68e52..67e940b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -15,6 +16,8 @@ class UserPreferencesService private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore<AppPreferences>, ) { + val flow = preferencesDataStore.data.map { UserPreferences(it) } + suspend fun getCurrent(): UserPreferences = serverRepository.currentUserDto.value!!.configuration.let { userConfig -> val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() 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 e6ae832a..991a8e98 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 @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services.hilt import android.content.Context import androidx.datastore.core.DataStore +import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository @@ -9,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module @@ -16,6 +18,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -43,6 +46,18 @@ annotation class StandardOkHttpClient @Retention(AnnotationRetention.BINARY) annotation class IoCoroutineScope +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultCoroutineScope + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher + @Module @InstallIn(SingletonComponent::class) object AppModule { @@ -56,12 +71,6 @@ object AppModule { version = BuildConfig.VERSION_NAME, ) - @Provides - @Singleton - fun deviceInfo( - @ApplicationContext context: Context, - ): DeviceInfo = androidDevice(context) - @StandardOkHttpClient @Provides @Singleton @@ -106,12 +115,12 @@ object AppModule { @Singleton fun okHttpFactory( @StandardOkHttpClient okHttpClient: OkHttpClient, - ) = OkHttpFactory(okHttpClient) + ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) @Provides @Singleton fun jellyfin( - okHttpFactory: OkHttpFactory, + okHttpFactory: CoroutineContextApiClientFactory, @ApplicationContext context: Context, clientInfo: ClientInfo, deviceInfo: DeviceInfo, @@ -171,10 +180,35 @@ object AppModule { } } + @Provides + @Singleton + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + @Provides @Singleton @IoCoroutineScope - fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + @Provides + @Singleton + @DefaultCoroutineScope + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + fun workManager( + @ApplicationContext context: Context, + ): WorkManager = WorkManager.getInstance(context) @Provides @Singleton @@ -182,3 +216,13 @@ object AppModule { @StandardOkHttpClient okHttpClient: OkHttpClient, ) = SeerrApi(okHttpClient) } + +@Module +@InstallIn(SingletonComponent::class) +object DeviceModule { + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = androidDevice(context) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index ed3f761b..31c92774 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations +import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerPreferencesDao @@ -66,6 +67,10 @@ object DatabaseModule { @Singleton fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + @Provides + @Singleton + fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt index 05ce1d2a..297cbd64 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -21,6 +21,7 @@ import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @ActivityScoped @@ -29,9 +30,9 @@ class TvProviderSchedulerService constructor( @param:ActivityContext private val context: Context, private val serverRepository: ServerRepository, + private val workManager: WorkManager, ) { private val activity = (context as AppCompatActivity) - private val workManager = WorkManager.getInstance(context) private val supportsTvProvider = // TODO <=25 has limited support @@ -60,7 +61,8 @@ class TvProviderSchedulerService TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), ), - ).build(), + ).setInitialDelay(60.seconds.toJavaDuration()) + .build(), ).await() } } 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 134e4903..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,7 @@ class TvProviderWorker getPotentialItems( userId, prefs.homePagePreferences.enableRewatchingNextUp, - prefs.homePagePreferences.combineContinueNext, + prefs.homePagePreferences.maxDaysNextUp, ) val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } @@ -144,19 +144,15 @@ class TvProviderWorker private suspend fun getPotentialItems( userId: UUID, enableRewatching: Boolean, - combineContinueNext: Boolean, + maxDaysNextUp: Int, ): List<BaseItem> { val resumeItems = latestNextUpService.getResume(userId, 10, true) val seriesIds = resumeItems.mapNotNull { it.data.seriesId } val nextUpItems = latestNextUpService - .getNextUp(userId, 10, enableRewatching, false) + .getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp) .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } - return if (combineContinueNext) { - latestNextUpService.buildCombined(resumeItems, nextUpItems) - } else { - resumeItems + nextUpItems - } + return latestNextUpService.buildCombined(resumeItems, nextUpItems) } private suspend fun getCurrentTvChannelNextUp(): List<WatchNextProgram> = 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..865c6d29 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 @@ -5,7 +5,6 @@ import android.content.Context import android.content.ContextWrapper import android.media.AudioManager import android.view.KeyEvent -import android.view.WindowManager import android.widget.Toast import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.basicMarquee @@ -46,7 +45,6 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID @@ -296,7 +294,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) = } /** - * Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn]. + * Tries to find the [Activity] for the given [Context] */ fun Context.findActivity(): Activity? { if (this is Activity) { @@ -310,18 +308,6 @@ fun Context.findActivity(): Activity? { return null } -/** - * Keep the screen on for an [Activity]. Often used with [findActivity]. - */ -fun Activity.keepScreenOn(keep: Boolean) { - Timber.v("Keep screen on: $keep") - if (keep) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } -} - /** * Selectively log errors from Coil image loading. * @@ -391,6 +377,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). * @@ -433,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems( useSeriesForPrimary: Boolean, ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } -@Composable -fun rememberBackDropImage(item: BaseItem): String? { - val imageUrlService = LocalImageUrlService.current - return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } -} - /** * Check if this, coalescing nulls to zero, is greater than that */ 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 e4f107b4..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) { @@ -167,19 +168,19 @@ fun listToDotString( strings.forEachIndexed { index, string -> append(string) if (index != strings.lastIndex) dot() - communityRating?.let { - dot() - append(String.format(Locale.getDefault(), "%.1f", it)) - appendInlineContent(id = "star") - } - criticRating?.let { - dot() - append("${it.toInt()}%") - if (it >= 60f) { - appendInlineContent(id = "fresh") - } else { - appendInlineContent(id = "rotten") - } + } + communityRating?.let { + dot() + append(String.format(Locale.getDefault(), "%.1f", it)) + appendInlineContent(id = "star") + } + criticRating?.let { + dot() + append("${it.toInt()}%") + if (it >= 60f) { + appendInlineContent(id = "fresh") + } else { + appendInlineContent(id = "rotten") } } } 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 54f564f1..ab0f69f8 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 @@ -57,6 +57,8 @@ val DefaultItemFields = ItemFields.CHAPTERS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, + ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, ) /** @@ -70,11 +72,22 @@ val SlimItemFields = ItemFields.OVERVIEW, ItemFields.SORT_NAME, ItemFields.MEDIA_SOURCE_COUNT, + ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, ) +val PhotoItemFields = + DefaultItemFields + + listOf( + ItemFields.WIDTH, + ItemFields.HEIGHT, + ) + 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/ChapterRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt index cf799c54..b1e64a2a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt @@ -34,6 +34,7 @@ fun ChapterRow( text = stringResource(R.string.chapters), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = rememberLazyListState(), @@ -41,7 +42,6 @@ fun ChapterRow( contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp), modifier = Modifier - .padding(start = 16.dp) .fillMaxWidth() .focusRestorer(), ) { 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 83166fa7..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,6 +58,7 @@ fun <T> ItemRow( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, + 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/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt index 2895db83..140cc2fd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt @@ -46,6 +46,7 @@ fun PersonRow( text = stringResource(title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = rememberLazyListState(), @@ -53,7 +54,6 @@ fun PersonRow( contentPadding = PaddingValues(8.dp), modifier = Modifier - .padding(start = 16.dp) .fillMaxWidth() .focusRestorer(firstFocus), ) { 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<Boolean> { + val focused by interactionSource.collectIsFocusedAsState() + val state = remember { mutableStateOf(false) } + + LaunchedEffect(focused) { + if (!focused) { + state.value = false + return@LaunchedEffect + } + delay(500L) + state.value = true + } + return state +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt new file mode 100644 index 00000000..d26675b1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt @@ -0,0 +1,215 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.layout.size +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.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RadialGradientShader +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.datastore.core.DataStore +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.useExistingImageAsPlaceholder +import coil3.request.ImageRequest +import coil3.request.transitionFactory +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.CrossFadeFactory +import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA +import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class ScreensaverViewModel + @Inject + constructor( + private val screensaverService: ScreensaverService, + val preferencesDataStore: DataStore<AppPreferences>, + ) : ViewModel() { + val currentItem = screensaverService.createItemFlow(viewModelScope) + } + +data class CurrentItem( + val item: BaseItem, + val backdropUrl: String, + val logoUrl: String?, + val title: String, +) + +@Composable +fun AppScreensaver( + prefs: AppPreferences, + modifier: Modifier = Modifier, + viewModel: ScreensaverViewModel = hiltViewModel(), +) { + val currentItem by viewModel.currentItem.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = prefs.interfacePreferences.screensaverPreference.showClock, + duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds, + animate = prefs.interfacePreferences.screensaverPreference.animate, + modifier = modifier, + ) +} + +@OptIn(ExperimentalCoilApi::class) +@Composable +fun AppScreensaverContent( + currentItem: CurrentItem?, + showClock: Boolean, + duration: Duration, + animate: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier + .background(Color.Black), + ) { + val infiniteTransition = rememberInfiniteTransition() + val scale by infiniteTransition.animateFloat( + 1f, + if (animate) 1.1f else 1f, + infiniteRepeatable( + tween( + durationMillis = duration.inWholeMilliseconds.toInt(), + delayMillis = 500, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + ) + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(currentItem?.backdropUrl) + .transitionFactory(CrossFadeFactory(2000.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) + + var logoError by remember(currentItem) { mutableStateOf(false) } + val alignment = listOf(Alignment.BottomStart, Alignment.BottomEnd).random() + if (!logoError) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(currentItem?.logoUrl) + .transitionFactory(CrossFadeFactory(750.milliseconds)) + .build(), + contentDescription = "Logo", + onError = { + logoError = true + }, + modifier = + Modifier + .align(alignment) + .size(width = 240.dp, height = 120.dp) + .padding(16.dp), + ) + } else { + Box( + modifier = + Modifier + .align(alignment) + .padding(16.dp) + .fillMaxWidth(.5f) + .fillMaxHeight(.3f), + ) { + Text( + text = currentItem?.title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(alignment), + ) + } + } + + val largeRadialGradient = + remember { + object : ShaderBrush() { + override fun createShader(size: Size): Shader { + val biggerDimension = maxOf(size.height, size.width) + return RadialGradientShader( + colors = listOf(Color.Transparent, AppColors.TransparentBlack25), + center = size.center, + radius = biggerDimension / 1.5f, + colorStops = listOf(0f, 0.85f), + ) + } + } + } + Canvas(Modifier.fillMaxSize()) { + drawRect( + brush = largeRadialGradient, + blendMode = BlendMode.Multiply, + ) + if (showClock) { + // Add scrim to make clock more readable + drawRect( + brush = + Brush.verticalGradient( + colorStops = + arrayOf( + 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), + TOP_SCRIM_END_FRACTION to Color.Transparent, + ), + ), + blendMode = BlendMode.Multiply, + ) + } + } + if (showClock) { + TimeDisplay() + } + } +} 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 29991126..8415c7a6 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 @@ -2,11 +2,11 @@ package com.github.damontecres.wholphin.ui.components import android.content.Context import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +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 +33,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 +42,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 @@ -55,10 +57,16 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService +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.services.deleteItem import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields @@ -71,15 +79,19 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.equalsNotNull +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast 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 @@ -92,6 +104,9 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -119,7 +134,11 @@ 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, + private val mediaManagementService: MediaManagementService, + val mediaReportService: MediaReportService, @Assisted itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @Assisted("recursive") private val recursive: Boolean, @@ -155,9 +174,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 -> @@ -182,11 +202,42 @@ class CollectionFolderViewModel } loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) + .join() +// onResumePage() } catch (ex: Exception) { Timber.e(ex, "Error during init") loading.setValueOnMain(DataLoadingState.Error(ex)) } } + mediaManagementService.deletedItemFlow + .onEach { deletedItem -> + refreshAfterDelete(position, deletedItem.item) + }.catch { ex -> + Timber.e(ex, "Error refreshing after deleted item") + }.launchIn(viewModelScope) + } + + private suspend fun refreshAfterDelete( + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + position.let { + Timber.v("Item deleted: position=%s, id=%s", it, itemId) + val item = pager?.get(it) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item?.id == deletedItem.id || + equalsNotNull(item?.data?.id, deletedItem.data.seriesId) + ) { + pager?.refreshPagesAfter(position) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing after deleted item %s", itemId) + showToast(context, "Error refreshing after item deleted") + } } private fun saveLibraryDisplayInfo( @@ -252,34 +303,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) } } } @@ -344,7 +393,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) }, @@ -436,6 +490,46 @@ 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) + } + } + } + } + + fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(index, item) + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } /** @@ -523,13 +617,14 @@ fun CollectionFolderGrid( var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) when (val state = loading) { DataLoadingState.Loading, DataLoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } is DataLoadingState.Error, @@ -541,6 +636,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, @@ -577,21 +679,45 @@ fun CollectionFolderGrid( onSaveViewOptions = { viewModel.saveViewOptions(it) }, onChangeBackdrop = viewModel::updateBackdrop, playEnabled = playEnabled, - onClickPlay = { _, item -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) + onClickPlay = { index, item -> + val destination = + if (item.type == BaseItemKind.PHOTO_ALBUM) { + Destination.Slideshow( + parentId = item.id, + index = index, + filter = CollectionFolderFilter(filter = filter), + sortAndDirection = sortAndDirection, + recursive = true, + startSlideshow = true, + ) + } else { + Destination.Playback(item) + } + viewModel.navigateTo(destination) }, onClickPlayAll = { shuffle -> itemId.toUUIDOrNull()?.let { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it, - startIndex = 0, - shuffle = shuffle, - recursive = recursive, - sortAndDirection = sortAndDirection, - filter = filter, - ), - ) + val destination = + if (item?.type == BaseItemKind.PHOTO_ALBUM) { + Destination.Slideshow( + parentId = it, + index = 0, + filter = CollectionFolderFilter(filter = filter), + sortAndDirection = sortAndDirection, + recursive = true, + startSlideshow = true, + ) + } else { + Destination.PlaybackList( + itemId = it, + startIndex = 0, + shuffle = shuffle, + recursive = recursive, + sortAndDirection = sortAndDirection, + filter = filter, + ) + } + viewModel.navigateTo(destination) } }, ) @@ -627,9 +753,10 @@ fun CollectionFolderGrid( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, + navigateTo = { viewModel.navigateTo(it) }, onClickWatch = { itemId, watched -> viewModel.setWatched(position, itemId, watched) }, @@ -640,6 +767,10 @@ fun CollectionFolderGrid( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog.makePresent(it) }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = PositionItem(position, item) + }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, @@ -664,8 +795,19 @@ fun CollectionFolderGrid( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun CollectionFolderGridContent( preferences: UserPreferences, @@ -728,8 +870,8 @@ fun CollectionFolderGridContent( ) { AnimatedVisibility( showHeader || loadingState !is DataLoadingState.Success, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -810,14 +952,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) { @@ -825,7 +969,7 @@ fun CollectionFolderGridContent( DataLoadingState.Loading, -> { // This shouldn't happen, so just show placeholder - Text("Loading") + Text(stringResource(R.string.loading)) } is DataLoadingState.Error -> { @@ -863,6 +1007,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..a96bc859 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 @@ -6,6 +6,8 @@ import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy +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.BoxScope @@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf 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.draw.drawBehind import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -56,8 +64,12 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream +import com.github.damontecres.wholphin.ui.playback.isDown +import com.github.damontecres.wholphin.ui.playback.isUp +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -259,31 +271,65 @@ fun DialogPopupContent( style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } } LazyColumn( + state = listState, modifier = Modifier, ) { - items(dialogItems) { - when (it) { + itemsIndexed(dialogItems) { index, item -> + when (item) { is DialogItemDivider -> { HorizontalDivider(Modifier.height(16.dp)) } is DialogItem -> { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( - selected = it.selected, - enabled = !waiting && it.enabled, + selected = item.selected, + enabled = !waiting && item.enabled, onClick = { if (dismissOnClick) { onDismissRequest.invoke() } - it.onClick.invoke() + item.onClick.invoke() }, - headlineContent = it.headlineContent, - overlineContent = it.overlineContent, - supportingContent = it.supportingContent, - leadingContent = it.leadingContent, - trailingContent = it.trailingContent, - modifier = Modifier, + headlineContent = item.headlineContent, + overlineContent = item.overlineContent, + supportingContent = item.supportingContent, + leadingContent = item.leadingContent, + trailingContent = item.trailingContent, + interactionSource = interactionSource, + modifier = + Modifier + .focusRequester(focusRequesters[index]) + .ifElse( + index == 0, + Modifier.onKeyEvent { + if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(dialogItems.lastIndex) + focusRequesters[dialogItems.lastIndex].tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ).ifElse( + index == dialogItems.lastIndex, + Modifier.onKeyEvent { + if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(0) + focusRequesters[0].tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ), ) } } @@ -408,12 +454,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 +474,7 @@ fun ConfirmDialogContent( onCancel: () -> Unit, onConfirm: () -> Unit, modifier: Modifier = Modifier, + bodyColor: Color = MaterialTheme.colorScheme.onSurface, ) { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -446,7 +494,7 @@ fun ConfirmDialogContent( item { Text( text = body, - color = MaterialTheme.colorScheme.onSurface, + color = bodyColor, ) } } @@ -469,6 +517,80 @@ fun ConfirmDialogContent( } } +@Composable +fun ConfirmDeleteDialog( + itemTitle: String, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + BasicDialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.wrapContentWidth(), + ) { + item { + Text( + text = stringResource(R.string.delete_item), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + item { + Text( + text = itemTitle, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(bottom = 8.dp), + ) + } + + item { + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier, + ) { + Button( + onClick = onCancel, + modifier = Modifier.width(72.dp), + ) { + Text( + text = stringResource(R.string.cancel), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + Button( + onClick = onConfirm, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + modifier = Modifier, + ) + Text( + text = stringResource(R.string.confirm), + ) + } + } + } + } + } + } +} + fun chooseVersionParams( context: Context, sources: List<MediaSourceInfo>, @@ -534,6 +656,9 @@ fun chooseStream( ) add( DialogItem( + leadingContent = { + SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED) + }, headlineContent = { Text(text = stringResource(R.string.only_forced_subtitles)) }, 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..d42eea5c 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<UUID, String?>() - 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,85 @@ class GenreViewModel } } +data class GenreCacheKey( + val userId: UUID?, + val parentId: UUID, +) + +private val genreCache by lazy { + InMemoryKache<GenreCacheKey, Map<UUID, String?>>(8) { + expireAfterWriteDuration = 2.hours + } +} + +suspend fun getGenreImageMap( + api: ApiClient, + userId: UUID?, + scope: CoroutineScope, + imageUrlService: ImageUrlService, + genres: List<UUID>, + parentId: UUID, + includeItemTypes: List<BaseItemKind>?, + cardWidthPx: Int?, + useCache: Boolean = true, +): Map<UUID, String?> { + val key = GenreCacheKey(userId, parentId) + if (useCache) { + genreCache.getIfAvailable(key)?.let { + Timber.v("Got cached entry") + return it + } + } + val genreToUrl = ConcurrentHashMap<UUID, String?>() + val semaphore = Semaphore(4) + genres + .map { genreId -> + scope.async(Dispatchers.IO) { + semaphore.withPermit { + val item = + GetItemsRequestHandler + .execute( + api, + GetItemsRequest( + userId = userId, + parentId = parentId, + recursive = true, + limit = 1, + sortBy = listOf(ItemSortBy.RANDOM), + fields = listOf(ItemFields.GENRES), + imageTypes = listOf(ImageType.BACKDROP), + imageTypeLimit = 1, + includeItemTypes = includeItemTypes, + genreIds = listOf(genreId), + enableTotalRecordCount = false, + ), + ).content.items + .firstOrNull() + if (item != null) { + genreToUrl[genreId] = + imageUrlService.getItemImageUrl( + itemId = item.id, + itemType = item.type, + seriesId = null, + useSeriesForPrimary = true, + imageType = ImageType.BACKDROP, + imageTags = item.imageTags.orEmpty(), + fillWidth = cardWidthPx, + backdropTags = item.backdropImageTags.orEmpty(), + ) + } + } + } + }.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 +281,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/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 2856f319..75a3147c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.focusGroup 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.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.requiredSizeIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -34,12 +39,16 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource 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.tv.material3.Icon +import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -63,12 +72,14 @@ fun ExpandablePlayButtons( resumePosition: Duration, watched: Boolean, favorite: Boolean, + canDelete: Boolean, trailers: List<Trailer>?, playOnClick: (position: Duration) -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, + deleteOnClick: () -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { @@ -149,6 +160,16 @@ fun ExpandablePlayButtons( ) } } + if (canDelete) { + item("delete") { + DeleteButton( + onClick = deleteOnClick, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } // More button item("more") { @@ -180,6 +201,63 @@ fun ExpandablePlayButton( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, mirrorIcon: Boolean = false, enabled: Boolean = true, +) = ExpandablePlayButton( + title = title, + resume = resume, + icon = { + Icon( + imageVector = icon, + contentDescription = null, + modifier = + Modifier + .size(28.dp) + .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), + ) + }, + onClick = onClick, + modifier = modifier, + interactionSource = interactionSource, + enabled = enabled, +) + +@Composable +fun ExpandablePlayButton( + @StringRes title: Int, + resume: Duration, + icon: Painter, + onClick: (position: Duration) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + mirrorIcon: Boolean = false, + enabled: Boolean = true, +) = ExpandablePlayButton( + title = title, + resume = resume, + icon = { + Icon( + painter = icon, + contentDescription = null, + modifier = + Modifier + .size(28.dp) + .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), + ) + }, + onClick = onClick, + modifier = modifier, + interactionSource = interactionSource, + enabled = enabled, +) + +@Composable +fun ExpandablePlayButton( + @StringRes title: Int, + resume: Duration, + icon: @Composable BoxScope.() -> Unit, + onClick: (position: Duration) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + enabled: Boolean = true, ) { val isFocused = interactionSource.collectIsFocusedAsState().value Button( @@ -195,19 +273,13 @@ fun ExpandablePlayButton( interactionSource = interactionSource, ) { Box( + contentAlignment = Alignment.Center, modifier = Modifier - .padding(start = 2.dp, top = 2.dp) + .padding(start = 2.dp) .height(MinButtonSize), ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = - Modifier - .size(28.dp) - .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), - ) + icon.invoke(this) } AnimatedVisibility(isFocused) { Spacer(Modifier.size(8.dp)) @@ -307,6 +379,48 @@ fun TrailerButton( } } +@Composable +fun DeleteButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focused by interactionSource.collectIsFocusedAsState() + val iconTint by + animateColorAsState( + targetValue = + if (focused) { + Color.Red.copy(alpha = .8f) + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.8f, + ) + }, + animationSpec = + tween( + easing = LinearEasing, + ), + ) + ExpandablePlayButton( + title = R.string.delete, + resume = Duration.ZERO, + icon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current, + modifier = + Modifier + .padding(start = 2.dp) + .size(24.dp), + ) + }, + onClick = { onClick.invoke() }, + interactionSource = interactionSource, + modifier = modifier, + ) +} + @PreviewTvSpec @Composable private fun ExpandablePlayButtonsPreview() { @@ -322,6 +436,8 @@ private fun ExpandablePlayButtonsPreview() { buttonOnFocusChanged = {}, trailers = listOf(), trailerOnClick = {}, + canDelete = true, + deleteOnClick = {}, modifier = Modifier, ) } @@ -343,6 +459,13 @@ private fun ViewOptionsPreview() { onClick = {}, interactionSource = source, ) + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = painterResource(R.drawable.baseline_pause_24), + onClick = {}, + interactionSource = source, + ) ExpandableFaButton( title = R.string.play, iconStringRes = R.string.fa_eye, @@ -358,12 +481,19 @@ private fun ViewOptionsPreview() { onClick = {}, modifier = Modifier, ) + DeleteButton( + onClick = {}, + ) SortByButton( sortOptions = listOf(), current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), onSortChange = {}, ) } + DeleteButton( + onClick = {}, + interactionSource = source, + ) } } } 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 869a454d..c3d1a4b6 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 @@ -19,10 +19,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn @@ -30,15 +34,18 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.launchDefault 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.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 @@ -46,7 +53,9 @@ abstract class RecommendedViewModel( val context: Context, val navigationManager: NavigationManager, val favoriteWatchManager: FavoriteWatchManager, + val mediaReportService: MediaReportService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, ) : ViewModel() { abstract fun init() @@ -97,13 +106,13 @@ abstract class RecommendedViewModel( abstract fun update( @StringRes title: Int, row: HomeRowLoadingState, - ) + ): HomeRowLoadingState fun update( @StringRes title: Int, block: suspend () -> List<BaseItem>, - ) { - viewModelScope.launch(Dispatchers.IO) { + ): Deferred<HomeRowLoadingState> = + viewModelScope.async(Dispatchers.IO) { val titleStr = context.getString(title) val row = try { @@ -113,7 +122,25 @@ abstract class RecommendedViewModel( } update(title, row) } + + fun deleteItem( + position: RowColumn, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + val row = rows.value.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + (row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column) + } + } + } } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } @Composable @@ -127,6 +154,7 @@ fun RecommendedContent( val context = LocalContext.current var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) OneTimeLaunchedEffect { @@ -137,18 +165,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()) }, @@ -158,7 +188,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, @@ -177,6 +221,7 @@ fun RecommendedContent( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( navigateTo = { viewModel.navigationManager.navigateTo(it) }, @@ -190,6 +235,8 @@ fun RecommendedContent( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog.makePresent(it) }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = RowColumnItem(position, item) }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, @@ -214,9 +261,19 @@ fun RecommendedContent( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } -private data class RowColumnItem( +data class RowColumnItem( val position: RowColumn, val item: BaseItem, ) 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 1272faf5..392c77a5 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 @@ -14,7 +14,11 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SuggestionService +import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.setValueOnMain @@ -22,7 +26,6 @@ import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler -import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted @@ -30,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.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull @@ -42,7 +47,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID @@ -54,11 +58,21 @@ class RecommendedMovieViewModel private val api: ApiClient, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore<AppPreferences>, + private val suggestionService: SuggestionService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, + mediaReportService: MediaReportService, backdropService: BackdropService, - ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) { + mediaManagementService: MediaManagementService, + ) : RecommendedViewModel( + context, + navigationManager, + favoriteWatchManager, + mediaReportService, + backdropService, + mediaManagementService, + ) { @AssistedFactory interface Factory { fun create(parentId: UUID): RecommendedMovieViewModel @@ -114,8 +128,10 @@ class RecommendedMovieViewModel } } + val jobs = mutableListOf<Deferred<HomeRowLoadingState>>() + update(R.string.recently_released) { - val recentlyReleasedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -128,13 +144,11 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, recentlyReleasedRequest) - .toBaseItems(api, false) - } + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + }.also(jobs::add) update(R.string.recently_added) { - val recentlyAddedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -147,27 +161,11 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, recentlyAddedRequest) - .toBaseItems(api, false) - } - - update(R.string.suggestions) { - val suggestionsRequest = - GetSuggestionsRequest( - userId = serverRepository.currentUser.value?.id, - type = listOf(BaseItemKind.MOVIE), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetSuggestionsRequestHandler - .execute(api, suggestionsRequest) - .toBaseItems(api, false) - } + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + }.also(jobs::add) update(R.string.top_unwatched) { - val unwatchedTopRatedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -181,13 +179,63 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, unwatchedTopRatedRequest) - .toBaseItems(api, false) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + }.also(jobs::add) + + viewModelScope.launch(Dispatchers.IO) { + try { + suggestionService + .getSuggestionsFlow(parentId, BaseItemKind.MOVIE) + .collect { resource -> + val state = + when (resource) { + is SuggestionsResource.Loading -> { + HomeRowLoadingState.Loading( + context.getString(R.string.suggestions), + ) + } + + is SuggestionsResource.Success -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + resource.items, + ) + } + + is SuggestionsResource.Empty -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + emptyList(), + ) + } + } + update(R.string.suggestions, state) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch suggestions") + update( + R.string.suggestions, + HomeRowLoadingState.Error( + title = context.getString(R.string.suggestions), + exception = ex, + ), + ) + } } + // 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..<jobs.size) { + val result = jobs[i].await() + if (result.completed) { + Timber.v("First success") + loading.setValueOnMain(LoadingState.Success) + } + break + } } } } @@ -195,10 +243,11 @@ class RecommendedMovieViewModel override fun update( @StringRes title: Int, row: HomeRowLoadingState, - ) { + ): HomeRowLoadingState { rows.update { current -> 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 fa4071b6..db7f7b4f 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 @@ -14,7 +14,11 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SuggestionService +import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.setValueOnMain @@ -23,7 +27,6 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler -import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted @@ -31,6 +34,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 @@ -45,7 +50,6 @@ import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID @@ -58,11 +62,21 @@ class RecommendedTvShowViewModel private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore<AppPreferences>, private val lastestNextUpService: LatestNextUpService, + private val suggestionService: SuggestionService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, + mediaReportService: MediaReportService, backdropService: BackdropService, - ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) { + mediaManagementService: MediaManagementService, + ) : RecommendedViewModel( + context, + navigationManager, + favoriteWatchManager, + mediaReportService, + backdropService, + mediaManagementService, + ) { @AssistedFactory interface Factory { fun create(parentId: UUID): RecommendedTvShowViewModel @@ -86,7 +100,7 @@ class RecommendedTvShowViewModel val userId = serverRepository.currentUser.value?.id try { val resumeItemsDeferred = - viewModelScope.async(Dispatchers.IO) { + async(Dispatchers.IO) { val resumeItemsRequest = GetResumeItemsRequest( userId = userId, @@ -104,7 +118,7 @@ class RecommendedTvShowViewModel } val nextUpItemsDeferred = - viewModelScope.async(Dispatchers.IO) { + async(Dispatchers.IO) { val nextUpRequest = GetNextUpRequest( userId = userId, @@ -116,19 +130,16 @@ class RecommendedTvShowViewModel enableUserData = true, enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp, ) - GetNextUpRequestHandler .execute(api, nextUpRequest) .toBaseItems(api, true) } + val resumeItems = resumeItemsDeferred.await() val nextUpItems = nextUpItemsDeferred.await() + if (combineNextUp) { - val combined = - lastestNextUpService.buildCombined( - resumeItems, - nextUpItems, - ) + val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems) update( R.string.continue_watching, HomeRowLoadingState.Success( @@ -138,10 +149,7 @@ class RecommendedTvShowViewModel ) update( R.string.next_up, - HomeRowLoadingState.Success( - context.getString(R.string.next_up), - listOf(), - ), + HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()), ) } else { update( @@ -153,10 +161,7 @@ class RecommendedTvShowViewModel ) update( R.string.next_up, - HomeRowLoadingState.Success( - context.getString(R.string.next_up), - nextUpItems, - ), + HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems), ) } @@ -170,8 +175,10 @@ class RecommendedTvShowViewModel } } + val jobs = mutableListOf<Deferred<HomeRowLoadingState>>() + update(R.string.recently_released) { - val recentlyReleasedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -184,14 +191,11 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - - GetItemsRequestHandler - .execute(api, recentlyReleasedRequest) - .toBaseItems(api, true) - } + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) + }.also(jobs::add) update(R.string.recently_added) { - val recentlyAddedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -204,29 +208,11 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - - GetItemsRequestHandler - .execute(api, recentlyAddedRequest) - .toBaseItems(api, true) - } - - update(R.string.suggestions) { - val suggestionsRequest = - GetSuggestionsRequest( - userId = serverRepository.currentUser.value?.id, - type = listOf(BaseItemKind.SERIES), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - - GetSuggestionsRequestHandler - .execute(api, suggestionsRequest) - .toBaseItems(api, true) - } + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) + }.also(jobs::add) update(R.string.top_unwatched) { - val unwatchedTopRatedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -240,13 +226,61 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, unwatchedTopRatedRequest) - .toBaseItems(api, true) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) + }.also(jobs::add) + + viewModelScope.launch(Dispatchers.IO) { + try { + suggestionService + .getSuggestionsFlow(parentId, BaseItemKind.SERIES) + .collect { resource -> + val state = + when (resource) { + is SuggestionsResource.Loading -> { + HomeRowLoadingState.Loading( + context.getString(R.string.suggestions), + ) + } + + is SuggestionsResource.Success -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + resource.items, + ) + } + + is SuggestionsResource.Empty -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + emptyList(), + ) + } + } + update(R.string.suggestions, state) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch suggestions") + update( + R.string.suggestions, + HomeRowLoadingState.Error( + title = context.getString(R.string.suggestions), + exception = ex, + ), + ) + } } if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { - loading.setValueOnMain(LoadingState.Success) + for (i in 0..<jobs.size) { + val result = jobs[i].await() + if (result is HomeRowLoadingState.Success) { + Timber.v("First success") + loading.setValueOnMain(LoadingState.Success) + } + break + } } } } @@ -254,10 +288,11 @@ class RecommendedTvShowViewModel override fun update( @StringRes title: Int, row: HomeRowLoadingState, - ) { + ): HomeRowLoadingState { rows.update { current -> current.toMutableList().apply { set(rowTitles[title]!!, row) } } + return row } companion object { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt index d9d69f6a..64cfc78b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt @@ -40,7 +40,7 @@ fun SliderBar( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interval: Int = 1, - color: Color = MaterialTheme.colorScheme.border, + colors: SliderColors = SliderColors.default(), ) { val isFocused by interactionSource.collectIsFocusedAsState() val animatedIndicatorHeight by animateDpAsState( @@ -49,9 +49,6 @@ fun SliderBar( var currentValue by remember(value) { mutableLongStateOf(value) } val percent = (currentValue - min).toFloat() / (max - min) - val activeColor = SliderActiveColor(isFocused) - val inactiveColor = SliderInactiveColor(isFocused) - val handleSeekEventModifier = Modifier.handleDPadKeyEvents( triggerOnAction = KeyEvent.ACTION_DOWN, @@ -91,14 +88,14 @@ fun SliderBar( onDraw = { val yOffset = size.height.div(2) drawLine( - color = inactiveColor, + color = if (isFocused) colors.inactiveFocused else colors.inactiveUnfocused, start = Offset(x = 0f, y = yOffset), end = Offset(x = size.width, y = yOffset), strokeWidth = size.height, cap = StrokeCap.Round, ) drawLine( - color = activeColor, + color = if (isFocused) colors.activeFocused else colors.activeUnfocused, start = Offset(x = 0f, y = yOffset), end = Offset( @@ -119,6 +116,24 @@ fun SliderBar( } } +data class SliderColors( + val activeFocused: Color, + val activeUnfocused: Color, + val inactiveFocused: Color, + val inactiveUnfocused: Color, +) { + companion object { + @Composable + fun default() = + SliderColors( + activeFocused = SliderActiveColor(true), + activeUnfocused = SliderActiveColor(false), + inactiveFocused = SliderInactiveColor(true), + inactiveUnfocused = SliderInactiveColor(false), + ) + } +} + @Composable fun SliderActiveColor(focused: Boolean): Color { val theme = LocalTheme.current 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 f028ed1e..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 <T : CardGridItem> CardGrid( }, columns: Int = 6, spacing: Dp = 16.dp, + bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current, ) { val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) @@ -269,100 +273,102 @@ fun <T : CardGridItem> CardGrid( Box( modifier = Modifier.weight(1f), ) { - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(spacing), - state = gridState, - contentPadding = PaddingValues(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}", + ) + } } } } @@ -394,7 +400,7 @@ fun <T : CardGridItem> CardGrid( modifier = Modifier .align(Alignment.CenterVertically) - .padding(end = 16.dp), + .padding(start = 16.dp), // Add end padding to push away from edge letterClicked = { letter -> scope.launch(ExceptionHandler()) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt index 91cd66f7..07c24c37 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -1,13 +1,11 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.layout.padding 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.Modifier -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -40,9 +38,7 @@ fun CollectionFolderBoxSet( recursive = recursive, sortOptions = BoxSetSortOptions, initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index 6db471e3..e0c15c26 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -1,13 +1,11 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.layout.padding 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.Modifier -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.ItemFilterBy @@ -45,15 +43,15 @@ fun CollectionFolderGeneric( } CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, + onClickItem = { index, item -> + preferencesViewModel.navigationManager.navigateTo(item.destination(index)) + }, itemId = itemId, initialFilter = filter, showTitle = showHeader, recursive = recursive, sortOptions = sortOptions, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 6d1fdd02..0d9bd338 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -127,14 +125,14 @@ fun CollectionFolderLiveTv( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .padding(vertical = 16.dp) .focusRequester(firstTabFocusRequester), tabs = tabs.map { it.title }, onClick = { selectedTabIndex = it }, @@ -176,9 +174,7 @@ fun CollectionFolderLiveTv( showTitle = false, recursive = false, sortOptions = VideoSortOptions, - modifier = - Modifier - .padding(start = 16.dp), + modifier = Modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index c47aeb23..6f67b011 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -76,14 +74,14 @@ fun CollectionFolderMovie( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .padding(vertical = 16.dp) .focusRequester(firstTabFocusRequester), tabs = tabs, onClick = { selectedTabIndex = it }, @@ -101,7 +99,6 @@ fun CollectionFolderMovie( }, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) @@ -129,7 +126,6 @@ fun CollectionFolderMovie( defaultViewOptions = ViewOptionsPoster, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), positionCallback = { columns, position -> @@ -162,7 +158,6 @@ fun CollectionFolderMovie( defaultViewOptions = ViewOptionsPoster, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), positionCallback = { columns, position -> @@ -180,7 +175,6 @@ fun CollectionFolderMovie( includeItemTypes = listOf(BaseItemKind.MOVIE), modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) 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 new file mode 100644 index 00000000..4ddc41c1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -0,0 +1,84 @@ +package com.github.damontecres.wholphin.ui.detail + +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.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel +import com.github.damontecres.wholphin.ui.components.ViewOptionsWide +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.data.VideoSortOptions +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.toServerString +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import java.util.UUID + +@Composable +fun CollectionFolderPhotoAlbum( + preferences: UserPreferences, + itemId: UUID, + recursive: Boolean, + modifier: Modifier = Modifier, + filter: CollectionFolderFilter = CollectionFolderFilter(), + filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, + sortOptions: List<ItemSortBy> = VideoSortOptions, + // Note: making the VM here and passing down is bad practice, but we need the grid state when clicking on items + // TODO refactor this + viewModel: CollectionFolderViewModel = + hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>( + key = itemId.toServerString(), + ) { + it.create( + itemId = itemId.toServerString(), + initialSortAndDirection = null, + recursive = recursive, + collectionFilter = filter, + useSeriesForPrimary = false, + defaultViewOptions = ViewOptionsWide, + ) + }, +) { + var showHeader by remember { mutableStateOf(true) } + CollectionFolderGrid( + preferences = preferences, + onClickItem = { index, item -> + val destination = + if (item.type == BaseItemKind.PHOTO) { + Destination.Slideshow( + parentId = itemId, + index = index, + filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()), + sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT, + recursive = true, + startSlideshow = false, + ) + } else { + item.destination(index) + } + viewModel.navigateTo(destination) + }, + itemId = itemId.toServerString(), + initialFilter = filter, + showTitle = showHeader, + recursive = recursive, + sortOptions = sortOptions, + modifier = modifier, + positionCallback = { columns, position -> + showHeader = position < columns + }, + defaultViewOptions = ViewOptionsWide, + playEnabled = true, + filterOptions = filterOptions, + viewModel = viewModel, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index 66ace2d7..e7e19066 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -1,13 +1,11 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.layout.padding 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.Modifier -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -35,9 +33,7 @@ fun CollectionFolderPlaylist( showTitle = showHeader, recursive = recursive, sortOptions = PlaylistSortOptions, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt index 5362ddb5..a58db1b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt @@ -1,13 +1,11 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.layout.padding 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.Modifier -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -35,9 +33,7 @@ fun CollectionFolderRecordings( showTitle = showHeader, recursive = recursive, sortOptions = MovieSortOptions, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index c9fd5381..68b9dd18 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -80,14 +78,14 @@ fun CollectionFolderTv( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .padding(vertical = 16.dp) .focusRequester(firstTabFocusRequester), tabs = tabs, onClick = { selectedTabIndex = it }, @@ -105,7 +103,6 @@ fun CollectionFolderTv( }, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) @@ -129,7 +126,6 @@ fun CollectionFolderTv( defaultViewOptions = ViewOptionsPoster, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), positionCallback = { columns, position -> @@ -150,7 +146,6 @@ fun CollectionFolderTv( includeItemTypes = listOf(BaseItemKind.SERIES), modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) 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 f0ad2c0c..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 @@ -49,6 +51,7 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.clientLogApi import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber import java.io.BufferedReader import java.io.InputStreamReader import javax.inject.Inject @@ -59,13 +62,19 @@ class DebugViewModel constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, - val refreshRateService: RefreshRateService, val clientInfo: ClientInfo, val deviceInfo: DeviceInfo, ) : ViewModel() { val itemPlaybacks = MutableLiveData<List<ItemPlayback>>(listOf()) val logcat = MutableLiveData<List<LogcatLine>>(listOf()) + val supportedModes by lazy { + val displayManager = + MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + display.supportedModes.orEmpty() + } + init { viewModelScope.launchIO { serverRepository.currentUser.value?.rowId?.let { @@ -137,9 +146,13 @@ class DebugViewModel Send App Logs clientInfo=$clientInfo deviceInfo=$deviceInfo + manufacturer=${Build.MANUFACTURER} + model=${Build.MODEL} + apiLevel=${Build.VERSION.SDK_INT} - """.trimIndent() + logcat - val response by api.clientLogApi.logFile(body) + """.trimIndent() + Timber.w(body) + val response by api.clientLogApi.logFile(body + logcat) showToast(context, "Sent! Filename=${response.fileName}") } } @@ -253,8 +266,9 @@ fun DebugPage( "DeviceInfo: ${viewModel.deviceInfo}", "Manufacturer: ${Build.MANUFACTURER}", "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/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 8a210c27..f34c0228 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -25,9 +25,11 @@ import kotlin.time.Duration.Companion.seconds data class MoreDialogActions( val navigateTo: (Destination) -> Unit, - var onClickWatch: (UUID, Boolean) -> Unit, - var onClickFavorite: (UUID, Boolean) -> Unit, - var onClickAddPlaylist: (UUID) -> Unit, + val onClickWatch: (UUID, Boolean) -> Unit, + val onClickFavorite: (UUID, Boolean) -> Unit, + val onClickAddPlaylist: (UUID) -> Unit, + val onSendMediaInfo: (UUID) -> Unit, + val onClickDelete: (BaseItem) -> Unit, ) enum class ClearChosenStreams { @@ -61,6 +63,7 @@ fun buildMoreDialogItems( watched: Boolean, favorite: Boolean, canClearChosenStreams: Boolean, + canDelete: Boolean, actions: MoreDialogActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, @@ -138,6 +141,17 @@ fun buildMoreDialogItems( actions.onClickAddPlaylist.invoke(item.id) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, @@ -205,6 +219,14 @@ fun buildMoreDialogItems( ) }, ) + add( + DialogItem( + text = R.string.send_media_info_log_to_server, + iconStringRes = R.string.fa_file_video, + ) { + actions.onSendMediaInfo.invoke(item.id) + }, + ) } fun buildMoreDialogItemsForHome( @@ -214,6 +236,7 @@ fun buildMoreDialogItemsForHome( playbackPosition: Duration, watched: Boolean, favorite: Boolean, + canDelete: Boolean, actions: MoreDialogActions, ): List<DialogItem> = buildList { @@ -281,6 +304,17 @@ fun buildMoreDialogItemsForHome( actions.onClickAddPlaylist.invoke(itemId) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, @@ -314,6 +348,14 @@ fun buildMoreDialogItemsForHome( }, ) } + add( + DialogItem( + text = R.string.send_media_info_log_to_server, + iconStringRes = R.string.fa_file_video, + ) { + actions.onSendMediaInfo.invoke(itemId) + }, + ) } fun buildMoreDialogItemsForPerson( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index fc337d88..50b0053e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -98,8 +96,8 @@ fun FavoritesPage( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 17535577..5a01d8a3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -298,9 +298,7 @@ fun PersonPageContent( LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), userScrollEnabled = !focusedOnHeader, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, ) { item { PersonHeader( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 7e552525..57c8c5d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -373,7 +373,6 @@ fun PlaylistDetailsContent( horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .padding(horizontal = 8.dp) .fillMaxWidth(), ) { PlaylistDetailsHeader( @@ -386,7 +385,7 @@ fun PlaylistDetailsContent( getPossibleFilterValues = getPossibleFilterValues, modifier = Modifier - .padding(start = 16.dp, top = 80.dp) + .padding(top = 80.dp) .fillMaxWidth(.25f), ) when (loadingState) { 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 3c22e8a9..a366e788 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 @@ -60,7 +60,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo -import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -102,23 +101,15 @@ fun DiscoverMovieDetails( val requestStr = stringResource(R.string.request) val request4kStr = stringResource(R.string.request_4k) - val moreActions = - MoreDialogActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> }, - onClickFavorite = { itemId, favorite -> }, - onClickAddPlaylist = { itemId -> }, - ) - 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/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt index 79ccb8dd..d8647132 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -65,7 +65,7 @@ fun DiscoverMovieDetailsHeader( ) { val padding = 4.dp val details = - remember(movie) { + remember(movie, rating) { buildList { movie.releaseDate?.let(::add) movie.runtime @@ -89,6 +89,7 @@ fun DiscoverMovieDetailsHeader( ?.releaseDates ?.firstOrNull() ?.certification + ?.takeIf { it.isNotNullOrBlank() } ?.let(::add) }.let { listToDotString( 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 dcaed1c5..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 -> { @@ -463,7 +463,7 @@ fun DiscoverSeriesDetailsHeader( ) { val padding = 4.dp val details = - remember(series) { + remember(series, rating) { buildList { series.firstAirDate?.let(::add) series.episodeRunTime 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 c46fe0cb..ee3cc4a2 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 @@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -82,6 +83,7 @@ fun EpisodeDetails( var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val moreActions = @@ -97,17 +99,19 @@ fun EpisodeDetails( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog.makePresent(itemId) }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = it }, ) when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { @@ -214,6 +218,7 @@ fun EpisodeDetails( onClearChosenStreams = { viewModel.clearChosenStreams(chosenStreams) }, + canDelete = viewModel.canDelete, ), ) }, @@ -223,6 +228,8 @@ fun EpisodeDetails( favoriteOnClick = { viewModel.setFavorite(ep.id, !ep.favorite) }, + canDelete = viewModel.canDelete, + deleteOnClick = { showDeleteDialog = ep }, modifier = modifier, ) } @@ -275,6 +282,16 @@ fun EpisodeDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 @@ -289,6 +306,8 @@ fun EpisodeDetailsContent( watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -303,7 +322,7 @@ fun EpisodeDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), + contentPadding = PaddingValues(vertical = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { @@ -346,6 +365,8 @@ fun EpisodeDetailsContent( }, trailers = null, trailerOnClick = {}, + canDelete = canDelete, + deleteOnClick = deleteOnClick, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index ee93050e..9b8bda5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -12,10 +12,13 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain @@ -48,10 +51,12 @@ class EpisodeViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val streamChoiceService: StreamChoiceService, + val mediaReportService: MediaReportService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -63,6 +68,9 @@ class EpisodeViewModel val item = MutableLiveData<BaseItem?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null) + var canDelete: Boolean = false + private set + init { init() } @@ -93,6 +101,7 @@ class EpisodeViewModel ) { val prefs = userPreferencesService.getCurrent() val item = fetchAndSetItem().await() + canDelete = mediaManagementService.canDelete(item) val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) withContext(Dispatchers.Main) { this@EpisodeViewModel.item.value = item @@ -197,4 +206,10 @@ class EpisodeViewModel } } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + navigationManager.goBack() + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 037a5ae7..30309304 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.text.format.DateUtils import android.widget.Toast import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement @@ -140,7 +142,11 @@ fun TvGuideGrid( .fillMaxHeight(.30f), ) } - AnimatedVisibility(focusedPosition.row < 1) { + AnimatedVisibility( + focusedPosition.row < 1, + enter = expandVertically(), + exit = shrinkVertically(), + ) { ExpandableFaButton( title = R.string.view_options, iconStringRes = R.string.fa_sliders, 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 58e7762d..a59ecde1 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 @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -111,6 +112,7 @@ fun MovieDetails( var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } val moreActions = MoreDialogActions( @@ -125,17 +127,19 @@ fun MovieDetails( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog.makePresent(itemId) }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = it }, ) when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { @@ -200,6 +204,7 @@ fun MovieDetails( seriesId = null, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, + canDelete = viewModel.canDelete, actions = moreActions, onChooseVersion = { chooseVersion = @@ -290,6 +295,7 @@ fun MovieDetails( playbackPosition = similar.playbackPosition, watched = similar.played, favorite = similar.favorite, + canDelete = false, actions = moreActions, ) moreDialog = @@ -309,6 +315,8 @@ fun MovieDetails( onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) }, + canDelete = viewModel.canDelete, + deleteOnClick = { showDeleteDialog = movie }, modifier = modifier, ) } @@ -361,6 +369,16 @@ fun MovieDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 @@ -394,6 +412,8 @@ fun MovieDetailsContent( onLongClickSimilar: (Int, BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -410,7 +430,7 @@ fun MovieDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), + contentPadding = PaddingValues(vertical = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { @@ -430,7 +450,7 @@ fun MovieDetailsContent( modifier = Modifier .fillMaxWidth() - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = 40.dp, bottom = 16.dp), ) ExpandablePlayButtons( resumePosition = resumePosition, @@ -456,6 +476,8 @@ fun MovieDetailsContent( position = TRAILER_ROW trailerOnClick.invoke(it) }, + canDelete = canDelete, + deleteOnClick = deleteOnClick, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 60451804..45e3ee54 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -46,46 +46,47 @@ fun MovieDetailsHeader( val context = LocalContext.current val scope = rememberCoroutineScope() Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { // Title Text( text = movie.name ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.SemiBold, - maxLines = 2, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - val padding = 4.dp QuickDetails( movie.ui.quickDetails, movie.timeRemainingOrRuntime, - Modifier.padding(bottom = padding), + Modifier.padding(start = 8.dp), ) dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(bottom = padding)) + GenreText(it, Modifier.padding(start = 8.dp)) } VideoStreamDetails( chosenStreams = chosenStreams, numberOfVersions = movie.data.mediaSourceCount ?: 0, - modifier = Modifier.padding(bottom = padding), + modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 16.dp), ) dto.taglines?.firstOrNull()?.let { tagline -> Text( text = tagline, style = MaterialTheme.typography.bodyLarge, fontStyle = FontStyle.Italic, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) } @@ -112,6 +113,7 @@ fun MovieDetailsHeader( movie.data.people ?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() } ?.joinToString(", ") { it.name!! } + ?.takeIf { it.isNotNullOrBlank() } } directorName @@ -120,6 +122,7 @@ fun MovieDetailsHeader( text = stringResource(R.string.directed_by, it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 8.dp), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 8454f0a1..993260dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -18,6 +18,8 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.SeerrService @@ -25,6 +27,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty @@ -64,6 +67,7 @@ class MovieViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val streamChoiceService: StreamChoiceService, + val mediaReportService: MediaReportService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val peopleFavorites: PeopleFavorites, @@ -71,6 +75,7 @@ class MovieViewModel private val extrasService: ExtrasService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -88,6 +93,9 @@ class MovieViewModel val chosenStreams = MutableLiveData<ChosenStreams?>(null) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) + var canDelete: Boolean = false + private set + init { init() } @@ -104,6 +112,7 @@ class MovieViewModel api.userLibraryApi.getItem(itemId).content.let { BaseItem.from(it, api) } + canDelete = mediaManagementService.canDelete(item) this@MovieViewModel.item.setValueOnMain(item) item } @@ -272,4 +281,10 @@ class MovieViewModel } } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + navigationManager.goBack() + } + } } 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..09df419c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt @@ -0,0 +1,256 @@ +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.VoiceSearchButton +import com.github.damontecres.wholphin.ui.main.SearchResult +import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun SearchForContent( + searchType: BaseItemKind, + onClick: (BaseItem) -> Unit, + modifier: Modifier = Modifier, + viewModel: SearchForViewModel = hiltViewModel(key = searchType.serialName), +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val state by viewModel.state.collectAsState() + + var query by rememberSaveable { mutableStateOf("") } + val searchFocusRequester = remember { FocusRequester() } + val focusRequester = remember { FocusRequester() } + + var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) } + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + viewModel.voiceInputManager.stopListening() + } + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + viewModel.search(searchType, searchQuery) + } + + LaunchedEffect(query) { + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750L) + viewModel.search(searchType, query) + } + } + } + val titleRes = + remember { + when (searchType) { + BaseItemKind.BOX_SET -> R.string.collections + BaseItemKind.PLAYLIST -> R.string.playlists + else -> null + } + } + val title = titleRes?.let { stringResource(it) } ?: "" + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = stringResource(R.string.search_for, title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + 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 { + ItemRow( + title = "", + 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/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index 9b6d81df..2aeac04e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -25,6 +25,8 @@ fun FocusedEpisodeFooter( moreOnClick: () -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { @@ -47,6 +49,8 @@ fun FocusedEpisodeFooter( buttonOnFocusChanged = buttonOnFocusChanged, trailers = null, trailerOnClick = {}, + canDelete = canDelete, + deleteOnClick = deleteOnClick, modifier = Modifier.fillMaxWidth(), ) } 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 305c6451..81beb82f 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 @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -24,13 +25,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -52,7 +56,9 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.components.DeleteButton import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup @@ -75,6 +81,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRowData +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt @@ -101,31 +108,35 @@ fun SeriesDetails( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() + val canDelete by viewModel.canDeleteSeries.collectAsState() val seasons by viewModel.seasons.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf()) val extras by viewModel.extras.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) val discovered by viewModel.discovered.collectAsState() + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var showWatchConfirmation by remember { mutableStateOf(false) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { @@ -149,6 +160,7 @@ fun SeriesDetails( similar = similar, played = played, favorite = item.data.userData?.isFavorite ?: false, + canDelete = canDelete, modifier = modifier, onClickItem = { index, item -> viewModel.navigateTo(item.destination()) @@ -162,23 +174,29 @@ fun SeriesDetails( ) }, onLongClickItem = { index, season -> - seasonDialog = - buildDialogForSeason( - context = context, - s = season, - onClickItem = { viewModel.navigateTo(it.destination()) }, - markPlayed = { played -> - viewModel.setSeasonWatched(season.id, played) - }, - onClickPlay = { shuffle -> - viewModel.navigateTo( - Destination.PlaybackList( - itemId = season.id, - shuffle = shuffle, - ), - ) - }, - ) + scope.launchDefault { + seasonDialog = + buildDialogForSeason( + context = context, + s = season, + canDelete = viewModel.canDelete(season), + onClickItem = { viewModel.navigateTo(it.destination()) }, + markPlayed = { played -> + viewModel.setSeasonWatched(season.id, played) + }, + onClickPlay = { shuffle -> + viewModel.navigateTo( + Destination.PlaybackList( + itemId = season.id, + shuffle = shuffle, + ), + ) + }, + onClickDelete = { + showDeleteDialog = it + }, + ) + } }, overviewOnClick = { overviewDialog = @@ -229,6 +247,10 @@ fun SeriesDetails( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog.makePresent(itemId) }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = it + }, ), ) if (showWatchConfirmation) { @@ -281,6 +303,19 @@ fun SeriesDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + if (seasons?.lastOrNull()?.id == item.id) { + focusManager.moveFocus(FocusDirection.Previous) + } + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 @@ -303,6 +338,7 @@ fun SeriesDetailsContent( discovered: List<DiscoverItem>, played: Boolean, favorite: Boolean, + canDelete: Boolean, onClickItem: (Int, BaseItem) -> Unit, onClickPerson: (Person) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, @@ -332,12 +368,12 @@ fun SeriesDetailsContent( Column( modifier = Modifier - .padding(16.dp) + .padding(vertical = 16.dp) .fillMaxSize(), ) { LazyColumn( contentPadding = PaddingValues(bottom = 80.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier, ) { item { @@ -354,7 +390,7 @@ fun SeriesDetailsContent( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(start = 16.dp) + .padding(start = 8.dp) .focusRequester(focusRequesters[HEADER_ROW]) .focusRestorer(playFocusRequester) .focusGroup() @@ -434,6 +470,23 @@ fun SeriesDetailsContent( } }, ) + if (canDelete) { + DeleteButton( + onClick = { + position = HEADER_ROW + moreActions.onClickDelete.invoke(series) + }, + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } } } item { @@ -531,6 +584,7 @@ fun SeriesDetailsContent( watched = item.played, favorite = item.favorite, actions = moreActions, + canDelete = false, ) moreDialog = DialogParams( @@ -599,23 +653,27 @@ fun SeriesDetailsHeader( val scope = rememberCoroutineScope() val dto = series.data Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { Text( text = series.name ?: stringResource(R.string.unknown), - style = MaterialTheme.typography.displaySmall, - maxLines = 2, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - QuickDetails(series.ui.quickDetails, null) + QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp)) dto.genres?.letNotEmpty { - GenreText(it) + GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp)) } dto.overview?.let { overview -> OverviewText( @@ -632,9 +690,11 @@ fun SeriesDetailsHeader( fun buildDialogForSeason( context: Context, s: BaseItem, + canDelete: Boolean, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, onClickPlay: (Boolean) -> Unit, + onClickDelete: (BaseItem) -> Unit, ): DialogParams { val items = buildList { @@ -673,6 +733,17 @@ fun buildDialogForSeason( onClickPlay.invoke(true) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + onClickDelete.invoke(s) + }, + ) + } } return DialogParams( title = s.name ?: context.getString(R.string.tv_season), 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 d95a5bbb..3ed615e9 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 @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState 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.focus.FocusRequester @@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -36,9 +38,11 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.seasonEpisode +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable @@ -89,6 +93,7 @@ fun SeriesOverview( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() } @@ -118,6 +123,7 @@ fun SeriesOverview( val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var rowFocused by rememberInt() + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } LaunchedEffect(episodes) { episodes?.let { episodes -> @@ -148,13 +154,13 @@ fun SeriesOverview( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { @@ -177,7 +183,7 @@ fun SeriesOverview( } } - fun buildMoreForEpisode( + suspend fun buildMoreForEpisode( ep: BaseItem, chosenStreams: ChosenStreams?, fromLongClick: Boolean, @@ -194,6 +200,7 @@ fun SeriesOverview( seriesId = series.id, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, + canDelete = viewModel.canDelete(ep), actions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -215,6 +222,10 @@ fun SeriesOverview( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog = it }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = it + }, ), onChooseVersion = { chooseVersion = @@ -314,7 +325,9 @@ fun SeriesOverview( ) }, onLongClick = { ep -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, true) + scope.launchDefault { + moreDialog = buildMoreForEpisode(ep, chosenStreams, true) + } }, playOnClick = { resume -> rowFocused = EPISODE_ROW @@ -342,18 +355,22 @@ fun SeriesOverview( }, moreOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, false) + scope.launchDefault { + moreDialog = buildMoreForEpisode(ep, chosenStreams, false) + } } }, overviewOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { - overviewDialog = - ItemDetailsDialogInfo( - title = it.name ?: context.getString(R.string.unknown), - overview = it.data.overview, - genres = it.data.genres.orEmpty(), - files = it.data.mediaSources.orEmpty(), - ) + scope.launchDefault { + overviewDialog = + ItemDetailsDialogInfo( + title = it.name ?: context.getString(R.string.unknown), + overview = it.data.overview, + genres = it.data.genres.orEmpty(), + files = it.data.mediaSources.orEmpty(), + ) + } } }, personOnClick = { @@ -366,6 +383,8 @@ fun SeriesOverview( ), ) }, + canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, + deleteOnClick = { showDeleteDialog = it }, modifier = modifier, ) } @@ -419,6 +438,17 @@ fun SeriesOverview( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.subtitle ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + episodeRowFocusRequester.tryRequestFocus() + showDeleteDialog = null + }, + ) + } } private const val EPISODE_ROW = 0 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 b19e7900..8acc32f1 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 @@ -88,6 +88,8 @@ fun SeriesOverviewContent( moreOnClick: () -> Unit, overviewOnClick: () -> Unit, personOnClick: (Person) -> Unit, + canDelete: (BaseItem) -> Boolean, + deleteOnClick: (BaseItem) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -127,14 +129,14 @@ fun SeriesOverviewContent( modifier = Modifier .fillMaxSize() - .padding(16.dp) + .padding(vertical = 16.dp) .focusGroup() .nestedScroll(scrollConnection) .verticalScroll(scrollState) .onFocusChanged { pageHasFocus = it.hasFocus }, ) { Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .focusGroup() @@ -142,9 +144,9 @@ fun SeriesOverviewContent( ) { val paddingValues = if (preferences.appPreferences.interfacePreferences.showClock) { - PaddingValues(start = 16.dp, end = 100.dp) + PaddingValues(start = 0.dp, end = 184.dp) } else { - PaddingValues(start = 16.dp, end = 16.dp) + PaddingValues(start = 0.dp, end = 16.dp) } TabRow( selectedTabIndex = selectedTabIndex, @@ -159,9 +161,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 +269,7 @@ fun SeriesOverviewContent( }, interactionSource = interactionSource, cardHeight = 120.dp, + useSeriesForPrimary = false, ) } } @@ -292,10 +296,12 @@ fun SeriesOverviewContent( } } }, + canDelete = canDelete.invoke(ep), + deleteOnClick = { deleteOnClick.invoke(ep) }, modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp), + .padding(top = 4.dp) + .fillMaxWidth(), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 8ae7a579..53924431 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -13,9 +13,12 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.SeerrService @@ -23,10 +26,12 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.gt +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.lt @@ -52,6 +57,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -85,9 +93,11 @@ class SeriesViewModel private val trailerService: TrailerService, private val extrasService: ExtrasService, val streamChoiceService: StreamChoiceService, + val mediaReportService: MediaReportService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val seerrService: SeerrService, + private val mediaManagementService: MediaManagementService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, @@ -109,6 +119,7 @@ class SeriesViewModel val extras = MutableLiveData<List<ExtrasItem>>(listOf()) val people = MutableLiveData<List<Person>>(listOf()) val similar = MutableLiveData<List<BaseItem>>() + val canDeleteSeries = MutableStateFlow(false) val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem()) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) @@ -125,6 +136,7 @@ class SeriesViewModel Timber.v("Start") addCloseable { themeSongPlayer.stop() } val item = fetchItem(seriesId) + canDeleteSeries.update { mediaManagementService.canDelete(item) } backdropService.submit(item) val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) @@ -221,6 +233,20 @@ class SeriesViewModel discovered.update { results } } } + mediaManagementService.deletedItemFlow + .onEach { deletedItem -> + if (deletedItem.item.data.seriesId == seriesId) { + Timber.d( + "Item %s deleted from series %s", + deletedItem.item.id, + seriesId, + ) + val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await() + this@SeriesViewModel.seasons.setValueOnMain(seasons) + } + }.catch { ex -> + Timber.e(ex, "Error refreshing after deleted item") + }.launchIn(viewModelScope) } } @@ -257,9 +283,12 @@ class SeriesViewModel if (seriesPageType == SeriesPageType.DETAILS) { listOf( ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, ) } else { - null + listOf( + ItemFields.CAN_DELETE, + ) }, ) val pager = @@ -298,6 +327,7 @@ class SeriesViewModel ItemFields.OVERVIEW, ItemFields.CUSTOM_RATING, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, ), ) Timber.v( @@ -526,7 +556,7 @@ class SeriesViewModel api.userLibraryApi .getItem(item.id) .content.people - ?.map { Person.fromDto(it, api) } + ?.map { Person.fromDto(context, it, api) } .orEmpty() PeopleInItem(item.id, list) @@ -549,6 +579,64 @@ class SeriesViewModel lookUpChosenTracks(item.id, item) } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + if (item.type == BaseItemKind.SERIES) { + navigationManager.goBack() + } else if (seriesPageType == SeriesPageType.DETAILS) { + this@SeriesViewModel.item.value?.let { series -> + val seasons = getSeasons(series, null).await() + if (seasons.isEmpty()) { + navigationManager.goBack() + } else { + this@SeriesViewModel.seasons.setValueOnMain(seasons) + } + } + } else { + position.value.let { (_, episodeIndex) -> + val eps = episodes.value as? EpisodeList.Success + if (eps != null) { + val pager = eps.episodes + val lastIndex = pager.lastIndex + pager.refreshPagesAfter(episodeIndex) + if (pager.isEmpty()) { + navigationManager.goBack() + } else { + if (episodeIndex == lastIndex) { + // Deleted last episode, so need to move left + episodes.setValueOnMain( + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex - 1, + ), + ) + position.update { it.copy(episodeRowIndex = episodeIndex - 1) } + } else { + episodes.setValueOnMain( + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex, + ), + ) + } + } + } + } + } + } + } + } + + suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item) + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } sealed interface EpisodeList { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt index 53b04161..5c18a0ea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.discover import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -60,8 +58,8 @@ fun DiscoverPage( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, 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 4c58b00c..3a7a457a 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,18 +47,23 @@ 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.ConfirmDeleteDialog 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.components.RowColumnItem import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.MoreDialogActions @@ -67,8 +75,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 +99,38 @@ fun HomePage( LaunchedEffect(Unit) { viewModel.init() } - val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) - val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading) - val watchingRows by viewModel.watchingRows.observeAsState(listOf()) - val latestRows by viewModel.latestRows.observeAsState(listOf()) - - val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows } + val state by viewModel.state.collectAsState() + val loading = state.loadingState + val refreshing = state.refreshState + val homeRows = state.homeRows when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { var dialog by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } + var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(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, @@ -125,6 +139,7 @@ fun HomePage( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( navigateTo = viewModel.navigationManager::navigateTo, @@ -138,6 +153,10 @@ fun HomePage( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog = itemId }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = RowColumnItem(position, item) + }, ), ) dialog = @@ -178,47 +197,62 @@ fun HomePage( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun HomePageContent( homeRows: List<HomeRowLoadingState>, + position: RowColumn, + onFocusPosition: (RowColumn) -> Unit, onClickItem: (RowColumn, BaseItem) -> Unit, onLongClickItem: (RowColumn, BaseItem) -> Unit, onClickPlay: (RowColumn, BaseItem) -> Unit, showClock: Boolean, onUpdateBackdrop: (BaseItem) -> Unit, modifier: Modifier = Modifier, - onFocusPosition: ((RowColumn) -> Unit)? = null, loadingState: LoadingState? = null, + listState: LazyListState = rememberLazyListState(), + takeFocus: Boolean = true, + showEmptyRows: Boolean = false, ) { - var position by rememberPosition() val focusedItem = position.let { (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) } - val listState = rememberLazyListState() val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } var firstFocused by remember { mutableStateOf(false) } - 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) + } + } } } } @@ -236,140 +270,113 @@ fun HomePageContent( item = focusedItem, modifier = Modifier - .padding(top = 48.dp, bottom = 32.dp, start = 32.dp) + .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .fillMaxHeight(.33f), ) - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = - PaddingValues( - start = 16.dp, - end = 16.dp, - top = 0.dp, - 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?.episdodeUnplayedCornerText, - 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(), + ) + } + } } } } @@ -408,7 +415,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, ) @@ -469,3 +476,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 1911e34d..53417863 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,36 +1,44 @@ 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.preferences.AppPreferences 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.MediaManagementService +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.deleteItem +import com.github.damontecres.wholphin.services.tvAccess +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.launchDefault 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 @@ -40,113 +48,123 @@ 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, + private val mediaManagementService: MediaManagementService, ) : ViewModel() { - val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) - val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending) - val watchingRows = MutableLiveData<List<HomeRowLoadingState>>(listOf()) - val latestRows = MutableLiveData<List<HomeRowLoadingState>>(listOf()) - - private lateinit var preferences: UserPreferences + private val _state = MutableStateFlow(HomeState.EMPTY) + val state: StateFlow<HomeState> = _state init { datePlayedService.invalidateAll() - init() +// init() } fun init() { - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loadingState, - "Error loading home page", - ), - ) { + viewModelScope.launchIO { Timber.d("init HomeViewModel") - val reload = loadingState.value != LoadingState.Success - if (reload) { - loadingState.setValueOnMain(LoadingState.Loading) - } - refreshState.setValueOnMain(LoadingState.Loading) - this@HomeViewModel.preferences = userPreferencesService.getCurrent() - val prefs = preferences.appPreferences.homePagePreferences - val limit = prefs.maxItemsPerRow - if (reload) { - backdropService.clearBackdrop() - } try { + val preferences = userPreferencesService.getCurrent() + val prefs = preferences.appPreferences.homePagePreferences + serverRepository.currentUserDto.value?.let { userDto -> - val includedIds = - navDrawerItemRepository - .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) - .filter { it is ServerNavDrawerItem } - .map { (it as ServerNavDrawerItem).itemId } - val resume = latestNextUpService.getResume(userDto.id, limit, true) - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - ) - 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)) + } } } } @@ -177,18 +195,59 @@ class HomeViewModel backdropService.submit(item) } } + + fun deleteItem( + position: RowColumn, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + val row = state.value.homeRows.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + _state.update { + val newRow = + row.items.toMutableList().apply { + removeAt(position.column) + } + it.copy( + homeRows = + it.homeRows.toMutableList().apply { + set(position.row, row.copy(items = newRow)) + }, + ) + } + } + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } -val supportedLatestCollectionTypes = - setOf( - CollectionType.MOVIES, - CollectionType.TVSHOWS, - CollectionType.HOMEVIDEOS, - // Exclude Live TV because a recording folder view will be used instead - null, // Recordings & mixed collection types - ) +data class HomeState( + val loadingState: LoadingState, + val refreshState: LoadingState, + val homeRows: List<HomeRowLoadingState>, + val settings: HomePageResolvedSettings, +) { + companion object { + val EMPTY = + HomeState( + LoadingState.Pending, + LoadingState.Pending, + listOf(), + HomePageResolvedSettings.EMPTY, + ) + } +} -data class LatestData( - val title: String, - val request: GetLatestMediaRequest, -) +/** + * Whether a row is a "is watching" type + */ +private fun isWatchingRow(row: HomeRowConfig) = + row is HomeRowConfig.ContinueWatching || + row is HomeRowConfig.NextUp || + row is HomeRowConfig.ContinueWatchingCombined 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<LibraryRowType> { + val supportsSuggestions = SuggestionsWorker.getTypeForCollection(library.collectionType) != null + return when { + library.isRecordingFolder -> { + listOf( + LibraryRowType.RECENTLY_RECORDED, + LibraryRowType.GENRES, + ) + } + + library.collectionType == CollectionType.LIVETV -> { + listOf( + LibraryRowType.TV_CHANNELS, + LibraryRowType.TV_PROGRAMS, + ) + } + + supportsSuggestions -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.SUGGESTIONS, + LibraryRowType.GENRES, + ) + } + + library.collectionType == CollectionType.BOXSETS -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + LibraryRowType.COLLECTION, + ) + } + + library.collectionType == CollectionType.PLAYLISTS -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + LibraryRowType.PLAYLIST, + ) + } + + else -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + ) + } + } +} + +enum class LibraryRowType( + @param:StringRes val stringId: Int, +) { + RECENTLY_ADDED(R.string.recently_added), + RECENTLY_RELEASED(R.string.recently_released), + SUGGESTIONS(R.string.suggestions), + GENRES(R.string.genres), + TV_CHANNELS(R.string.channels), + TV_PROGRAMS(R.string.live_tv), + RECENTLY_RECORDED(R.string.recently_recorded), + COLLECTION(R.string.collection), + PLAYLIST(R.string.playlist), +} 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<PreferenceGroup<HomeRowViewOptions>>, + viewOptions: HomeRowViewOptions, + onViewOptionsChange: (HomeRowViewOptions) -> Unit, + onApplyApplyAll: () -> Unit, + modifier: Modifier = Modifier, + defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(), +) { + val firstFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText(title) + LazyColumn { + preferenceOptions.forEachIndexed { groupIndex, prefGroup -> + if (preferenceOptions.size > 1) { + item { + TitleText(stringResource(prefGroup.title)) + } + } + itemsIndexed(prefGroup.preferences) { index, pref -> + pref as AppPreference<HomeRowViewOptions, Any> + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(viewOptions) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) + }, + interactionSource = interactionSource, + onClickPreference = { pref -> + when (pref) { + Options.ViewOptionsReset -> { + onViewOptionsChange.invoke(defaultViewOptions) + } + + Options.ViewOptionsApplyAll -> { + onApplyApplyAll.invoke() + } + + Options.ViewOptionsUseThumb -> { + onViewOptionsChange.invoke( + viewOptions.copy( + heightDp = Cards.HEIGHT_EPISODE, + spacing = 20, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.FIT, + episodeImageType = ViewOptionImageType.THUMB, + episodeAspectRatio = AspectRatio.WIDE, + episodeContentScale = PrefContentScale.FIT, + ), + ) + } + } + }, + modifier = + Modifier + .ifElse( + groupIndex == 0 && index == 0, + Modifier.focusRequester(firstFocus), + ), + ) + } + } + } + } +} + +internal object Options { + val ViewOptionsCardHeight = + AppSliderPreference<HomeRowViewOptions>( + title = R.string.height, + defaultValue = Cards.HEIGHT_2X3_DP.toLong(), + min = 64L, + max = Cards.HEIGHT_2X3_DP + 64L, + interval = 4, + getter = { it.heightDp.toLong() }, + setter = { prefs, value -> prefs.copy(heightDp = value.toInt()) }, + ) + val ViewOptionsSpacing = + AppSliderPreference<HomeRowViewOptions>( + title = R.string.spacing, + defaultValue = 16, + min = 0, + max = 32, + interval = 2, + getter = { it.spacing.toLong() }, + setter = { prefs, value -> prefs.copy(spacing = value.toInt()) }, + ) + + val ViewOptionsContentScale = + AppChoicePreference<HomeRowViewOptions, PrefContentScale>( + title = R.string.global_content_scale, + defaultValue = PrefContentScale.FIT, + displayValues = R.array.content_scale, + getter = { it.contentScale }, + setter = { viewOptions, value -> viewOptions.copy(contentScale = value) }, + indexToValue = { PrefContentScale.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val ViewOptionsAspectRatio = + AppChoicePreference<HomeRowViewOptions, AspectRatio>( + title = R.string.aspect_ratio, + defaultValue = AspectRatio.TALL, + displayValues = R.array.aspect_ratios, + getter = { it.aspectRatio }, + setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) }, + indexToValue = { AspectRatio.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsShowTitles = + AppSwitchPreference<HomeRowViewOptions>( + title = R.string.show_titles, + defaultValue = true, + getter = { it.showTitles }, + setter = { vo, value -> vo.copy(showTitles = value) }, + ) + + val ViewOptionsUseSeries = + AppSwitchPreference<HomeRowViewOptions>( + title = R.string.use_series, + defaultValue = true, + getter = { it.useSeries }, + setter = { vo, value -> vo.copy(useSeries = value) }, + ) + + val ViewOptionsImageType = + AppChoicePreference<HomeRowViewOptions, ViewOptionImageType>( + title = R.string.image_type, + defaultValue = ViewOptionImageType.PRIMARY, + displayValues = R.array.image_types, + getter = { it.imageType }, + setter = { viewOptions, value -> + val aspectRatio = + when (value) { + ViewOptionImageType.PRIMARY -> AspectRatio.TALL + ViewOptionImageType.THUMB -> AspectRatio.WIDE + } + viewOptions.copy(imageType = value, aspectRatio = aspectRatio) + }, + indexToValue = { ViewOptionImageType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsApplyAll = + AppClickablePreference<HomeRowViewOptions>( + title = R.string.apply_all_rows, + ) + + val ViewOptionsReset = + AppClickablePreference<HomeRowViewOptions>( + title = R.string.reset, + ) + + val ViewOptionsUseThumb = + AppClickablePreference<HomeRowViewOptions>( + title = R.string.use_thumb_images, + ) + + val ViewOptionsEpisodeContentScale = + AppChoicePreference<HomeRowViewOptions, PrefContentScale>( + title = R.string.global_content_scale, + defaultValue = PrefContentScale.FIT, + displayValues = R.array.content_scale, + getter = { it.episodeContentScale }, + setter = { viewOptions, value -> viewOptions.copy(episodeContentScale = value) }, + indexToValue = { PrefContentScale.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val ViewOptionsEpisodeAspectRatio = + AppChoicePreference<HomeRowViewOptions, AspectRatio>( + title = R.string.aspect_ratio, + defaultValue = AspectRatio.TALL, + displayValues = R.array.aspect_ratios, + getter = { it.episodeAspectRatio }, + setter = { viewOptions, value -> viewOptions.copy(episodeAspectRatio = value) }, + indexToValue = { AspectRatio.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsEpisodeImageType = + AppChoicePreference<HomeRowViewOptions, ViewOptionImageType>( + title = R.string.image_type, + defaultValue = ViewOptionImageType.PRIMARY, + displayValues = R.array.image_types, + getter = { it.episodeImageType }, + setter = { viewOptions, value -> + val aspectRatio = + when (value) { + ViewOptionImageType.PRIMARY -> AspectRatio.TALL + ViewOptionImageType.THUMB -> AspectRatio.WIDE + } + viewOptions.copy(episodeImageType = value, episodeAspectRatio = aspectRatio) + }, + indexToValue = { ViewOptionImageType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val OPTIONS = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsShowTitles, + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsContentScale, + ViewOptionsUseSeries, + ), + ), + PreferenceGroup( + title = R.string.more, + preferences = + listOf( +// ViewOptionsApplyAll, + ViewOptionsUseThumb, + ViewOptionsReset, + ), + ), + ) + + val OPTIONS_EPISODES = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsShowTitles, + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsContentScale, + ), + ), + PreferenceGroup( + title = R.string.for_episodes, + preferences = + listOf( + ViewOptionsUseSeries, + ViewOptionsEpisodeImageType, + ViewOptionsEpisodeAspectRatio, + ViewOptionsEpisodeContentScale, + ), + ), + PreferenceGroup( + title = R.string.more, + preferences = + listOf( + ViewOptionsUseThumb, + ViewOptionsReset, + ), + ), + ) + + val GENRE_OPTIONS = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsReset, + ), + ), + ) +} 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<Library>, + showDiscover: Boolean, + onClick: (Library) -> Unit, + onClickMeta: (MetaRowType) -> Unit, + modifier: Modifier, + firstFocus: FocusRequester = remember { FocusRequester() }, +) { +// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText(stringResource(R.string.add_row)) + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + itemsIndexed( + listOf( + MetaRowType.CONTINUE_WATCHING, + MetaRowType.NEXT_UP, + MetaRowType.COMBINED_CONTINUE_WATCHING, + ), + ) { index, type -> + HomeSettingsListItem( + selected = false, + headlineText = stringResource(type.stringId), + onClick = { onClickMeta.invoke(type) }, + modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + item { + TitleText(stringResource(R.string.library)) + HorizontalDivider() + } + itemsIndexed(libraries) { index, library -> + HomeSettingsListItem( + selected = false, + headlineText = library.name, + onClick = { onClick.invoke(library) }, + modifier = Modifier, // .ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + item { + TitleText(stringResource(R.string.more)) + HorizontalDivider() + } + itemsIndexed( + listOf( + MetaRowType.FAVORITES, + MetaRowType.COLLECTION, + MetaRowType.PLAYLIST, + ), + ) { index, type -> + HomeSettingsListItem( + selected = false, + headlineText = stringResource(type.stringId), + onClick = { onClickMeta.invoke(type) }, + modifier = Modifier, + ) + } + if (showDiscover) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(MetaRowType.DISCOVER.stringId), + onClick = { onClickMeta.invoke(MetaRowType.DISCOVER) }, + modifier = Modifier, + ) + } + } + } + } +} + +enum class MetaRowType( + @param:StringRes val stringId: Int, +) { + CONTINUE_WATCHING(R.string.continue_watching), + NEXT_UP(R.string.next_up), + COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next), + FAVORITES(R.string.favorites), + DISCOVER(R.string.discover), + COLLECTION(R.string.collection), + PLAYLIST(R.string.playlist), +} 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..fb025c02 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -0,0 +1,345 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.HomePageContent +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings +import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber + +val settingsWidth = 360.dp + +@Composable +fun HomeSettingsPage( + modifier: Modifier, + viewModel: HomeSettingsViewModel = hiltViewModel(), +) { + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val backStack = rememberNavBackStack(HomeSettingsDestination.RowList) + var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) } + var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) } + + val state by viewModel.state.collectAsState() + var position by rememberPosition(0, 0) + // TODO discover rows + val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) + + // Adds a row, waits until its done loading, then scrolls to the new row + fun addRow( + scrollToBottom: Boolean = true, + func: () -> Job, + ) { + scope.launch(ExceptionHandler(autoToast = true)) { + while (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } + func.invoke().join() + if (scrollToBottom) { + listState.animateScrollToItem(state.rows.lastIndex) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Box( + modifier = + Modifier + .width(settingsWidth) + .fillMaxHeight() + .background(color = MaterialTheme.colorScheme.surface), + ) { + NavDisplay( + backStack = backStack, +// onBack = { navigationManager.goBack() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), + entryProvider = { key -> + val dest = key as HomeSettingsDestination + NavEntry(dest, contentKey = key.toString()) { + val destModifier = + Modifier + .fillMaxSize() + .padding(8.dp) + when (dest) { + HomeSettingsDestination.RowList -> { + HomeSettingsRowList( + state = state, + onClickAdd = { backStack.add(HomeSettingsDestination.AddRow) }, + onClickSettings = { backStack.add(HomeSettingsDestination.GlobalSettings) }, + onClickPresets = { backStack.add(HomeSettingsDestination.Presets) }, + onClickMove = viewModel::moveRow, + onClickDelete = viewModel::deleteRow, + onClick = { index, row -> + backStack.add(RowSettings(row.id)) + scope.launch(ExceptionHandler()) { + Timber.v("Scroll to $index") + listState.animateScrollToItem(index) + // Update backdrop to first item in the row + (state.rowData.getOrNull(index) as? HomeRowLoadingState.Success) + ?.items + ?.firstOrNull() + ?.let { + viewModel.updateBackdrop(it) + } + position = RowColumn(index, 0) + } + }, + modifier = destModifier, + ) + } + + is HomeSettingsDestination.AddRow -> { + HomeSettingsAddRow( + libraries = state.libraries, + showDiscover = discoverEnabled, + onClick = { backStack.add(ChooseRowType(it)) }, + onClickMeta = { + when (it) { + MetaRowType.CONTINUE_WATCHING, + MetaRowType.NEXT_UP, + MetaRowType.COMBINED_CONTINUE_WATCHING, + -> { + addRow { viewModel.addRow(it) } + } + + MetaRowType.FAVORITES -> { + backStack.add(HomeSettingsDestination.ChooseFavorite) + } + + MetaRowType.DISCOVER -> { + backStack.add(HomeSettingsDestination.ChooseDiscover) + } + + MetaRowType.COLLECTION -> { + searchForDialog = BaseItemKind.BOX_SET + } + + MetaRowType.PLAYLIST -> { + searchForDialog = BaseItemKind.PLAYLIST + } + } + }, + modifier = destModifier, + ) + } + + is ChooseRowType -> { + HomeLibraryRowTypeList( + library = dest.library, + onClick = { type -> + when (type) { + LibraryRowType.COLLECTION -> { + searchForDialog = BaseItemKind.BOX_SET + } + + LibraryRowType.PLAYLIST -> { + searchForDialog = BaseItemKind.PLAYLIST + } + + else -> { + addRow { viewModel.addRow(dest.library, type) } + } + } + }, + modifier = destModifier, + ) + } + + is RowSettings -> { + val row = + state.rows + .first { it.id == dest.rowId } + val preferenceOptions = + remember(row.config) { + when (row.config) { + is HomeRowConfig.ContinueWatching, + is HomeRowConfig.ContinueWatchingCombined, + -> Options.OPTIONS_EPISODES + + is HomeRowConfig.Genres -> Options.GENRE_OPTIONS + + else -> Options.OPTIONS + } + } + val defaultViewOptions = + remember(row.config) { + when (row.config) { + is HomeRowConfig.Genres -> HomeRowViewOptions.genreDefault + else -> HomeRowViewOptions() + } + } + HomeRowSettings( + title = row.title, + preferenceOptions = preferenceOptions, + viewOptions = row.config.viewOptions, + defaultViewOptions = defaultViewOptions, + onViewOptionsChange = { + viewModel.updateViewOptions(dest.rowId, it) + }, + onApplyApplyAll = { + viewModel.updateViewOptionsForAll(row.config.viewOptions) + }, + modifier = destModifier, + ) + } + + HomeSettingsDestination.ChooseDiscover -> { + TODO() + } + + HomeSettingsDestination.ChooseFavorite -> { + HomeSettingsFavoriteList( + onClick = { type -> + addRow { viewModel.addFavoriteRow(type) } + }, + modifier = destModifier, + ) + } + + 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<AppPreferences>, + @param:IoCoroutineScope private val ioScope: CoroutineScope, + ) : ViewModel() { + private val _state = MutableStateFlow(HomePageSettingsState.EMPTY) + val state: StateFlow<HomePageSettingsState> = _state + + private var idCounter by Delegates.notNull<Int>() + + val discoverEnabled = seerrServerRepository.active + + private var originalLocalSettings: HomePageSettings? = null + private var originalRemoteSettings: HomePageSettings? = null + + init { + addCloseable { saveToLocal() } + viewModelScope.launchIO { + val userDto = serverRepository.currentUserDto.value ?: return@launchIO + val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) + val currentSettings = + homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } + originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id) + originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id) + Timber.v("currentSettings=%s", currentSettings) + idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 + _state.update { + it.copy( + libraries = libraries, + rows = currentSettings.rows, + ) + } + fetchRowData() + } + } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + private suspend fun fetchRowData() { + val limit = 8 + val semaphore = Semaphore(4) + val rows = + serverRepository.currentUserDto.value?.let { userDto -> + val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences + state.value + .let { state -> + state.rows + .map { it.config } + .map { row -> + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + homeSettingsService.fetchDataForRow( + row = row, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = state.libraries, + limit = limit, + isRefresh = false, + ) + } + } + } + }.awaitAll() + } + rows?.let { rows -> + rows + .firstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } + ?.let { + it as HomeRowLoadingState.Success + it.items.firstOrNull()?.let { + Timber.v("Updating backdrop") + updateBackdrop(it) + } + } + updateState { + it.copy(loading = LoadingState.Success, rowData = rows) + } + } + } + + private fun <T> List<T>.move( + direction: MoveDirection, + index: Int, + ): List<T> = + toMutableList().apply { + if (direction == MoveDirection.DOWN) { + val down = this[index] + val up = this[index + 1] + set(index, up) + set(index + 1, down) + } else { + val up = this[index] + val down = this[index - 1] + set(index - 1, up) + set(index, down) + } + } + + fun moveRow( + direction: MoveDirection, + index: Int, + ) { + viewModelScope.launchIO { + updateState { + val rows = it.rows.move(direction, index) + val rowData = it.rowData.move(direction, index) + it.copy( + rows = rows, + rowData = rowData, + ) + } + } +// viewModelScope.launchIO { fetchRowData() } + } + + fun deleteRow(index: Int) { + viewModelScope.launchIO { + updateState { + val rows = it.rows.toMutableList().apply { removeAt(index) } + val rowData = it.rowData.toMutableList().apply { removeAt(index) } + it.copy( + rows = rows, + rowData = rowData, + ) + } + } + } + + fun addRow(type: MetaRowType): Job = + viewModelScope.launchIO { + val id = idCounter++ + val newRow = + when (type) { + MetaRowType.CONTINUE_WATCHING -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.continue_watching), + config = ContinueWatching(), + ) + } + + MetaRowType.NEXT_UP -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.next_up), + config = NextUp(), + ) + } + + MetaRowType.COMBINED_CONTINUE_WATCHING -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.combine_continue_next), + config = ContinueWatchingCombined(), + ) + } + + MetaRowType.FAVORITES, + MetaRowType.COLLECTION, + MetaRowType.PLAYLIST, + -> { + throw IllegalArgumentException("Should use a different addRow() instead") + } + + MetaRowType.DISCOVER -> { + TODO() + } + } + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun addRow( + library: Library, + rowType: LibraryRowType, + ): Job = + viewModelScope.launchIO { + val id = idCounter++ + val newRow = + when (rowType) { + LibraryRowType.RECENTLY_ADDED -> { + val title = + library.name.let { context.getString(R.string.recently_added_in, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyAdded(library.itemId), + ) + } + + LibraryRowType.RECENTLY_RELEASED -> { + val title = + library.name.let { + context.getString( + R.string.recently_released_in, + it, + ) + } + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyReleased(library.itemId), + ) + } + + LibraryRowType.GENRES -> { + val title = library.name.let { context.getString(R.string.genres_in, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = Genres(library.itemId), + ) + } + + LibraryRowType.SUGGESTIONS -> { + val title = + library.name.let { context.getString(R.string.suggestions_for, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = Suggestions(library.itemId), + ) + } + + LibraryRowType.TV_CHANNELS -> { + val title = context.getString(R.string.channels) + HomeRowConfigDisplay( + id = id, + title = title, + config = + TvChannels( + viewOptions = HomeRowViewOptions.liveTvDefault, + ), + ) + } + + LibraryRowType.TV_PROGRAMS -> { + val title = context.getString(R.string.watch_live) + HomeRowConfigDisplay( + id = id, + title = title, + config = TvPrograms(), + ) + } + + LibraryRowType.RECENTLY_RECORDED -> { + val title = context.getString(R.string.recently_recorded) + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyAdded(library.itemId), + ) + } + + LibraryRowType.COLLECTION, + LibraryRowType.PLAYLIST, + -> { + throw IllegalArgumentException("Use different addRow") + } + } + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun addFavoriteRow(type: BaseItemKind): Job = + viewModelScope.launchIO { + Timber.v("Adding favorite row for $type") + val id = idCounter++ + val newRow = + HomeRowConfigDisplay( + id = id, + title = + context.getString( + R.string.favorite_items, + context.getString(favoriteOptions[type]!!), + ), + config = HomeRowConfig.Favorite(type), + ) + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun addRow( + type: BaseItemKind, + parent: BaseItem, + ) = viewModelScope.launchIO { + Timber.v("Adding %s row for %s", type, parent.id) + val id = idCounter++ + val newRow = + HomeRowConfigDisplay( + id = id, + title = parent.name ?: "", + config = + HomeRowConfig.ByParent( + parentId = parent.id, + recursive = true, + ), + ) + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun updateViewOptions( + rowId: Int, + viewOptions: HomeRowViewOptions, + ) { + viewModelScope.launchIO { + var fetchData = false + updateState { + val index = it.rows.indexOfFirst { it.id == rowId } + val config = it.rows[index].config + val newRowConfig = config.updateViewOptions(viewOptions) + val newRow = it.rows[index].copy(config = newRowConfig) + if (config.viewOptions.useSeries != viewOptions.useSeries) { + fetchData = true + } + it.copy( + rows = + it.rows.toMutableList().apply { + set(index, newRow) + }, + rowData = + it.rowData.toMutableList().apply { + val row = it.rowData[index] + val newRow = + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = viewOptions) + } else { + row + } + set(index, newRow) + }, + ) + } + if (fetchData) { + fetchRowData() + } + } + } + + fun updateViewOptionsForAll(viewOptions: HomeRowViewOptions) { + viewModelScope.launchIO { + updateState { + it.copy( + rowData = + it.rowData.toMutableList().map { row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = viewOptions) + } else { + row + } + }, + ) + } + } + } + + fun saveToRemote() { + viewModelScope.launchIO { + serverRepository.currentUser.value?.let { user -> + Timber.d("Saving home settings to remote") + val rows = state.value.rows.map { it.config } + val settings = + HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + try { + Timber.d("saveToRemote") + homeSettingsService.saveToServer(user.id, settings) + showSaveToast() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error saving: ${ex.localizedMessage}") + } + } + } + } + + fun loadFromRemote() { + viewModelScope.launchIO { + serverRepository.currentUser.value?.let { user -> + Timber.d("Loading home settings from remote") + try { + _state.update { it.copy(loading = LoadingState.Loading) } + val result = homeSettingsService.loadFromServer(user.id) + if (result != null) { + Timber.v("Got remote settings") + val newRows = + result.rows.mapIndexed { index, config -> + homeSettingsService.resolve(index, config) + } + idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 + _state.update { + it.copy(rows = newRows) + } + } else { + Timber.v("No remote settings") + showToast(context, "No server-side settings found") + } + fetchRowData() + } catch (ex: UnsupportedHomeSettingsVersionException) { + // TODO + Timber.w(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + } + + fun loadFromRemoteWeb() { + viewModelScope.launchIO { + serverRepository.currentUser.value?.let { user -> + Timber.d("Loading home settings from web") + try { + _state.update { it.copy(loading = LoadingState.Loading) } + val result = homeSettingsService.parseFromWebConfig(user.id) + if (result != null) { + Timber.v("Got web settings") + idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 + _state.update { + it.copy(rows = result.rows) + } + } else { + Timber.v("No web settings") + showToast(context, "No server-side web settings found") + } + fetchRowData() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + } + + fun saveToLocal() { + // This uses injected ioScope so that it will still run when the page is closing + ioScope.launchIO { + serverRepository.currentUser.value?.let { user -> + val rows = state.value.rows.map { it.config } + val settings = + HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + try { + Timber.d("saveToLocal") + // Only save if there are changes based on original source + val shouldSave = + if (originalLocalSettings != null) { + originalLocalSettings != settings + } else if (originalRemoteSettings != null) { + originalRemoteSettings != settings + } else { + true + } + if (shouldSave) { + homeSettingsService.saveToLocal(user.id, settings) + homeSettingsService.updateCurrent(settings) + showSaveToast() + } else { + Timber.d("No changes") + } + } catch (ex: UnsupportedHomeSettingsVersionException) { + Timber.w(ex, "Overwriting local settings") + homeSettingsService.saveToLocal(user.id, settings) + showSaveToast() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error saving: ${ex.localizedMessage}") + } + } + } + } + + private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) { + _state.update { + update.invoke(it) + } + homeSettingsService.currentSettings.update { HomePageResolvedSettings(state.value.rows) } + } + + fun resizeCards(relative: Int) { + viewModelScope.launchIO { + updateState { + val newRows = + it.rows.toMutableList().map { row -> + val vo = row.config.viewOptions + val newVo = vo.copy(heightDp = vo.heightDp + (4 * relative)) + row.copy(config = row.config.updateViewOptions(newVo)) + } + it.copy( + rows = newRows, + rowData = + it.rowData.toMutableList().mapIndexed { index, row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = newRows[index].config.viewOptions) + } else { + row + } + }, + ) + } + } + } + + fun resetToDefault() = + viewModelScope.launchIO { + val userId = serverRepository.currentUser.value?.id ?: return@launchIO + _state.update { it.copy(loading = LoadingState.Loading) } + val result = homeSettingsService.createDefault(userId) + idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 + _state.update { + it.copy(rows = result.rows, rowData = emptyList()) + } + fetchRowData() + } + + private suspend fun showSaveToast() = + showToast( + context, + context.getString(R.string.settings_saved), + Toast.LENGTH_SHORT, + ) + + fun applyPreset(preset: HomeRowPresets) { + _state.update { it.copy(loading = LoadingState.Loading) } + viewModelScope.launchIO { + val state = state.value + + val typeCache = mutableMapOf<UUID, CollectionType?>() + + suspend fun getCollectionType(itemId: UUID): CollectionType = + typeCache.getOrPut(itemId) { + state.libraries + .firstOrNull { it.itemId == itemId } + ?.collectionType + ?: api.userLibraryApi + .getItem(itemId) + .content.collectionType ?: CollectionType.UNKNOWN + } ?: CollectionType.UNKNOWN + + val newRows = + state.rows.map { + val newConfig = + when (it.config) { + is ContinueWatching, + is NextUp, + is ContinueWatchingCombined, + -> { + it.config.updateViewOptions(preset.continueWatching) + } + + is HomeRowConfig.ByParent -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.Favorite -> { + val viewOptions = + when (it.config.kind) { + BaseItemKind.MOVIE -> preset.movieLibrary + BaseItemKind.SERIES -> preset.tvLibrary + BaseItemKind.EPISODE -> preset.continueWatching + BaseItemKind.VIDEO -> preset.videoLibrary + BaseItemKind.PLAYLIST -> preset.playlist + BaseItemKind.PERSON -> preset.movieLibrary + else -> preset.movieLibrary + } + it.config.updateViewOptions(viewOptions) + } + + is Genres -> { + it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize)) + } + + is HomeRowConfig.GetItems -> { + it.config + } + + is RecentlyAdded -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is RecentlyReleased -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.Recordings -> { + it.config.updateViewOptions(preset.tvLibrary) + } + + is Suggestions -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.TvPrograms -> { + it.config.updateViewOptions(preset.liveTv) + } + + is HomeRowConfig.TvChannels -> { + it.config.updateViewOptions(preset.liveTv) + } + } + it.copy(config = newConfig) + } + + _state.update { + it.copy( + loading = LoadingState.Success, + rows = newRows, + rowData = + it.rowData.toMutableList().mapIndexed { index, row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = newRows[index].config.viewOptions) + } else { + row + } + }, + ) + } + } + } + } + +data class HomePageSettingsState( + val loading: LoadingState, + val rows: List<HomeRowConfigDisplay>, + val rowData: List<HomeRowLoadingState>, + val libraries: List<Library>, +) { + companion object { + val EMPTY = + HomePageSettingsState( + LoadingState.Pending, + emptyList(), + emptyList(), + emptyList(), + ) + } +} + +@Immutable +@Serializable +data class Library( + @Serializable(UUIDSerializer::class) val itemId: UUID, + val name: String, + val type: BaseItemKind, + val collectionType: CollectionType, + val isRecordingFolder: Boolean, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 2ada8504..5f01f20d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -6,18 +6,21 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -32,7 +35,9 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.serialization.NavBackStackSerializer import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.DrawerValue import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.rememberDrawerState import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.transitionFactory @@ -50,8 +55,8 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds // Top scrim configuration for text readability (clock, season tabs) -private const val TOP_SCRIM_ALPHA = 0.55f -private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height +const val TOP_SCRIM_ALPHA = 0.55f +const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height @HiltViewModel class ApplicationContentViewModel @@ -91,6 +96,7 @@ fun ApplicationContent( navigationManager.backStack = backStack val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle + val drawerState = rememberDrawerState(DrawerValue.Closed) Box( modifier = modifier, ) { @@ -181,9 +187,15 @@ fun ApplicationContent( .align(Alignment.TopEnd) .fillMaxHeight(.7f) .fillMaxWidth(.7f) - .alpha(.95f) + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) .drawWithContent { drawContent() + if (drawerState.isOpen) { + drawRect( + brush = SolidColor(Color.Black), + alpha = .75f, + ) + } // Subtle top scrim for system UI readability (clock, tabs) if (enableTopScrim) { drawRect( @@ -220,6 +232,7 @@ fun ApplicationContent( ) } } + val navDrawerListState = rememberLazyListState() NavDisplay( backStack = navigationManager.backStack, onBack = { navigationManager.goBack() }, @@ -245,6 +258,8 @@ fun ApplicationContent( preferences = preferences, user = user, server = server, + drawerState = drawerState, + navDrawerListState = navDrawerListState, onClearBackdrop = viewModel::clearBackdrop, modifier = Modifier.fillMaxSize(), ) 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 426d5328..59d102c4 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,11 +33,19 @@ sealed class Destination( val id: Long = 0L, ) : Destination() + @Serializable + data object HomeSettings : Destination(true) + @Serializable data class Settings( val screen: PreferenceScreenOption, ) : Destination(true) + @Serializable + data class SubtitleSettings( + val hdr: Boolean, + ) : Destination(true) + @Serializable data object Search : Destination() @@ -100,6 +108,16 @@ sealed class Destination( val itemIds: List<UUID>, ) : Destination(false) + @Serializable + data class Slideshow( + val parentId: UUID, + val index: Int, + val filter: CollectionFolderFilter, + val sortAndDirection: SortAndDirection, + val recursive: Boolean, + val startSlideshow: Boolean, + ) : Destination(true) + @Serializable data object Favorites : Destination(false) 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 70ef71d2..0888e721 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 @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie +import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv @@ -32,9 +33,12 @@ 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 import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage +import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import timber.log.Timber @@ -60,6 +64,10 @@ fun DestinationContent( ) } + is Destination.HomeSettings -> { + HomeSettingsPage(modifier) + } + is Destination.PlaybackList, is Destination.Playback, -> { @@ -78,6 +86,14 @@ fun DestinationContent( ) } + is Destination.SubtitleSettings -> { + SubtitleStylePage( + preferences.appPreferences, + destination.hdr, + modifier, + ) + } + is Destination.SeriesOverview -> { SeriesOverview( preferences = preferences, @@ -105,7 +121,9 @@ fun DestinationContent( ) } - BaseItemKind.VIDEO -> { + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + -> { // TODO Use VideoDetails MovieDetails( preferences, @@ -186,9 +204,19 @@ fun DestinationContent( ) } + BaseItemKind.PHOTO_ALBUM -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + CollectionFolderPhotoAlbum( + preferences = preferences, + itemId = destination.itemId, + recursive = true, + modifier = modifier, + ) + } + else -> { Timber.w("Unsupported item type: ${destination.type}") - Text("Unsupported item type: ${destination.type}") + Text("Unsupported item type: ${destination.type}", modifier) } } } @@ -225,6 +253,12 @@ fun DestinationContent( ) } + is Destination.Slideshow -> { + SlideshowPage( + slideshow = destination, + ) + } + Destination.Favorites -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } FavoritesPage( 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 f38d50bd..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 @@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.ui.nav import android.content.Context import androidx.activity.compose.BackHandler -import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.animateIntOffsetAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -12,10 +14,12 @@ 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.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size 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.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Home @@ -24,26 +28,27 @@ import androidx.compose.material.icons.filled.Search 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 import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -56,29 +61,25 @@ import androidx.tv.material3.DrawerValue import androidx.tv.material3.Icon import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.ModalNavigationDrawer -import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text -import androidx.tv.material3.rememberDrawerState 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 @@ -86,13 +87,7 @@ import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -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 @@ -105,42 +100,66 @@ class NavDrawerViewModel @Inject constructor( private val api: ApiClient, - private val navDrawerItemRepository: NavDrawerItemRepository, + private val navDrawerService: NavDrawerService, val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, - private val seerrServerRepository: SeerrServerRepository, ) : ViewModel() { - val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null) - val libraries = MutableLiveData<List<NavDrawerItem>>(listOf()) + val state = navDrawerService.state val selectedIndex = MutableLiveData(-1) - val showMore = MutableLiveData(false) + val moreExpanded = MutableLiveData(false) - init { - seerrServerRepository.active - .onEach { - init() - }.launchIn(viewModelScope) + fun onClickDrawerItem( + index: Int, + item: NavDrawerItem, + ) { + if (item !is NavDrawerItem.More) setShowMore(false) + when (item) { + NavDrawerItem.Favorites -> { + setIndex(index) + navigationManager.navigateToFromDrawer( + Destination.Favorites, + ) + } + + 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 @@ -179,47 +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) - } - } - } - - 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 { @@ -255,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() + } } /** @@ -269,6 +251,8 @@ fun NavDrawer( preferences: UserPreferences, user: JellyfinUser, server: JellyfinServer, + drawerState: DrawerState, + navDrawerListState: LazyListState, onClearBackdrop: () -> Unit, modifier: Modifier = Modifier, viewModel: NavDrawerViewModel = @@ -277,76 +261,58 @@ fun NavDrawer( key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either ), ) { - val drawerState = rememberDrawerState(DrawerValue.Closed) - val listState = rememberLazyListState() + LaunchedEffect(Unit) { viewModel.updateSelectedIndex() } val scope = rememberCoroutineScope() val context = LocalContext.current + val density = LocalDensity.current val focusRequester = remember { FocusRequester() } - val searchFocusRequester = remember { FocusRequester() } // If the user presses back while on the home page, open the nav drawer, another back press will quit the app BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) { 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) } - val closedDrawerWidth = NavigationDrawerItemDefaults.CollapsedDrawerItemWidth - val drawerBackground by animateColorAsState( - if (drawerState.isOpen) { - MaterialTheme.colorScheme.surface - } else { - Color.Transparent - }, + val closedDrawerWidth = CollapsedDrawerItemWidth + val openDrawerWidth = ExpandedDrawerItemWidth + val offset by animateIntOffsetAsState( + targetValue = + IntOffset( + x = + with(density) { + if (drawerState.isOpen) (openDrawerWidth - closedDrawerWidth).roundToPx() else 0 + }, + y = 0, + ), + animationSpec = + spring( + stiffness = DrawerAnimationStiffness, + visibilityThreshold = IntOffset.VisibilityThreshold, + ), ) - val spacedBy = 4.dp - val config = LocalConfiguration.current - val density = LocalDensity.current - val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } } - - suspend fun scrollToSelected() { - val target = selectedIndex + 2 - try { - if (target !in - listState.firstVisibleItemIndex..<listState.layoutInfo.visibleItemsInfo.lastIndex - ) { - val mult = if ((target - 2) < listState.layoutInfo.totalItemsCount / 2) -1 else 1 - listState.animateScrollToItem(selectedIndex + 2, mult * (heightInPx / 2)) - } - } catch (ex: Exception) { - Timber.w(ex, "Error scrolling to %s", target) - } - } - - LaunchedEffect(selectedIndex) { - scrollToSelected() - } ModalNavigationDrawer( modifier = modifier, drawerState = drawerState, - drawerContent = { + drawerContent = { drawerValue -> + val isOpen = drawerValue.isOpen + val spacedBy = 4.dp + val searchFocusRequester = remember { FocusRequester() } + ProvideTextStyle(MaterialTheme.typography.labelMedium) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(spacedBy), - modifier = - Modifier - .fillMaxHeight() - .drawBehind { - drawRect(drawerBackground) - }, + modifier = Modifier.fillMaxHeight(), ) { // Even though some must be clicked, focusing on it should clear other focused items val interactionSource = remember { MutableInteractionSource() } @@ -355,7 +321,7 @@ fun NavDrawer( user = user, imageUrl = userImageUrl, serverName = server.name ?: server.url, - drawerOpen = drawerState.isOpen, + drawerOpen = isOpen, interactionSource = interactionSource, onClick = { viewModel.setupNavigationManager.navigateTo( @@ -365,7 +331,7 @@ fun NavDrawer( modifier = Modifier, ) LazyColumn( - state = listState, + state = navDrawerListState, contentPadding = PaddingValues(0.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedByWithFooter(spacedBy), @@ -380,11 +346,6 @@ fun NavDrawer( focusRequester.tryRequestFocus() } } - onExit = { - scope.launch(ExceptionHandler()) { - scrollToSelected() - } - } }.fillMaxHeight(), ) { item { @@ -393,7 +354,7 @@ fun NavDrawer( text = stringResource(R.string.search), icon = Icons.Default.Search, selected = selectedIndex == -2, - drawerOpen = drawerState.isOpen, + drawerOpen = isOpen, interactionSource = interactionSource, onClick = { viewModel.setIndex(-2) @@ -414,7 +375,7 @@ fun NavDrawer( text = stringResource(R.string.home), icon = Icons.Default.Home, selected = selectedIndex == -1, - drawerOpen = drawerState.isOpen, + drawerOpen = isOpen, interactionSource = interactionSource, onClick = { viewModel.setIndex(-1) @@ -432,58 +393,90 @@ fun NavDrawer( ), ) } - itemsIndexed(libraries) { index, it -> - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = it, - selected = selectedIndex == index, - moreExpanded = showMore, - drawerOpen = drawerState.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, - drawerOpen = drawerState.isOpen, - onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) }, - containerColor = - if (drawerState.isOpen) { - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - } else { - Color.Unspecified - }, + selected = selectedIndex == index, + moreExpanded = moreExpanded, + drawerOpen = isOpen, 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( text = stringResource(R.string.settings), icon = Icons.Default.Settings, selected = false, - drawerOpen = drawerState.isOpen, + drawerOpen = isOpen, interactionSource = interactionSource, onClick = { viewModel.navigationManager.navigateTo( @@ -501,10 +494,7 @@ fun NavDrawer( }, ) { Box( - modifier = - Modifier - .padding(start = closedDrawerWidth) - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), ) { // Drawer content DestinationContent( @@ -513,7 +503,10 @@ fun NavDrawer( onClearBackdrop = onClearBackdrop, modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .offset { + offset + }.padding(start = closedDrawerWidth + 8.dp, end = 16.dp), ) if (preferences.appPreferences.interfacePreferences.showClock) { TimeDisplay() @@ -542,7 +535,7 @@ fun NavigationDrawerScope.ProfileIcon( name = user.name, imageUrl = imageUrl, alpha = if (drawerOpen) 1f else .5f, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.size(DrawerIconSize), ) }, supportingContent = { @@ -583,7 +576,7 @@ fun NavigationDrawerScope.IconNavItem( icon, contentDescription = null, tint = color, - modifier = Modifier, + modifier = Modifier.size(DrawerIconSize), ) }, supportingContent = @@ -673,7 +666,7 @@ fun NavigationDrawerScope.NavItem( painter = painterResource(icon), contentDescription = null, tint = color, - modifier = Modifier, + modifier = Modifier.size(DrawerIconSize), ) } } @@ -700,6 +693,7 @@ fun NavigationDrawerScope.NavItem( } @Composable +@ReadOnlyComposable fun navItemColor( selected: Boolean, focused: Boolean, @@ -753,4 +747,6 @@ fun navItemColor( } } -val DrawerState.isOpen: Boolean get() = this.currentValue == DrawerValue.Open +val DrawerState.isOpen: Boolean get() = this.currentValue.isOpen + +val DrawerValue.isOpen: Boolean get() = this == DrawerValue.Open diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt new file mode 100644 index 00000000..30b9d1d6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt @@ -0,0 +1,229 @@ +/* + * This file contains code copied and modified from: + * https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-androidx-tv-material-release/tv/tv-material/src/main/java/androidx/tv/material3/ + * + * Their license & required attribution is below. + * + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.damontecres.wholphin.ui.nav + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.tv.material3.DrawerState +import androidx.tv.material3.DrawerValue +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.NavigationDrawerItemColors +import androidx.tv.material3.NavigationDrawerItemDefaults +import androidx.tv.material3.NavigationDrawerScope +import androidx.tv.material3.rememberDrawerState + +@Composable +fun ModalNavigationDrawer( + drawerContent: @Composable NavigationDrawerScope.(DrawerValue) -> Unit, + modifier: Modifier = Modifier, + drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), + content: @Composable () -> Unit, +) { + Box(modifier = modifier) { + DrawerSheet( + modifier = + Modifier + .align(Alignment.CenterStart), + drawerState = drawerState, + content = drawerContent, + ) + + content() + } +} + +@Composable +private fun DrawerSheet( + modifier: Modifier = Modifier, + drawerState: DrawerState = remember { DrawerState() }, + content: @Composable NavigationDrawerScope.(DrawerValue) -> Unit, +) { + // indicates that the drawer has been set to its initial state and has grabbed focus if + // necessary. Controls whether focus is used to decide the state of the drawer going forward. + var initializationComplete: Boolean by remember { mutableStateOf(false) } + var focusState by remember { mutableStateOf<FocusState?>(null) } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(key1 = drawerState.currentValue) { + if (drawerState.currentValue == DrawerValue.Open && focusState?.hasFocus == false) { + // used to grab focus if the drawer state is set to Open on start. + focusRequester.requestFocus() + } + initializationComplete = true + } + + val internalModifier = + Modifier + .focusRequester(focusRequester) + .animateContentSize( + animationSpec = + spring( + stiffness = DrawerAnimationStiffness, + visibilityThreshold = IntSize.VisibilityThreshold, + ), + ).fillMaxHeight() + // adding passed-in modifier here to ensure animateContentSize is called before other + // size based modifiers. + .then(modifier) + .onFocusChanged { + focusState = it + + if (initializationComplete) { + drawerState.setValue(if (it.hasFocus) DrawerValue.Open else DrawerValue.Closed) + } + }.focusGroup() + + Box(modifier = internalModifier) { + NavigationDrawerScopeImpl(drawerState.currentValue == DrawerValue.Open).apply { + content(drawerState.currentValue) + } + } +} + +internal class NavigationDrawerScopeImpl( + override val hasFocus: Boolean, +) : NavigationDrawerScope + +internal val CollapsedDrawerItemWidth = 64.dp +internal val ExpandedDrawerItemWidth = 224.dp +internal val DrawerIconSize = 24.dp +internal val DrawerIconPadding = (ListItemDefaults.IconSize - DrawerIconSize) / 2 +internal val DrawerAnimationStiffness = Spring.StiffnessMedium + +@Composable +internal fun NavigationDrawerScope.NavigationDrawerItem( + selected: Boolean, + onClick: () -> Unit, + leadingContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, + supportingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + tonalElevation: Dp = NavigationDrawerItemDefaults.NavigationDrawerItemElevation, + colors: NavigationDrawerItemColors = NavigationDrawerItemDefaults.colors(), + interactionSource: MutableInteractionSource? = null, + content: @Composable () -> Unit, +) { + val animatedWidth by + animateDpAsState( + targetValue = + if (hasFocus) { + ExpandedDrawerItemWidth + } else { + CollapsedDrawerItemWidth + }, + label = "NavigationDrawerItem width open/closed state of the drawer item", + ) + val navDrawerItemHeight = + if (supportingContent == null) { + NavigationDrawerItemDefaults.ContainerHeightOneLine + } else { + NavigationDrawerItemDefaults.ContainerHeightTwoLine + } + + ListItem( + selected = selected, + onClick = onClick, + headlineContent = content, + leadingContent = { + Box( + Modifier + .padding(horizontal = DrawerIconPadding) + .size(DrawerIconSize), + ) { leadingContent() } + }, + trailingContent = trailingContent, + supportingContent = supportingContent, + modifier = + modifier + .layout { measurable, constraints -> + val width = animatedWidth.roundToPx() + val height = navDrawerItemHeight.roundToPx() + val placeable = + measurable.measure( + constraints.copy( + minWidth = width, + maxWidth = width, + minHeight = height, + maxHeight = height, + ), + ) + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + }, + enabled = enabled, + onLongClick = onLongClick, + tonalElevation = tonalElevation, + colors = colors.toToggleableListItemColors(hasFocus), + scale = ListItemDefaults.scale(1f, 1f), + interactionSource = interactionSource, + ) +} + +@Composable +private fun NavigationDrawerItemColors.toToggleableListItemColors(doesNavigationDrawerHaveFocus: Boolean) = + ListItemDefaults.colors( + containerColor = containerColor, + contentColor = if (doesNavigationDrawerHaveFocus) contentColor else inactiveContentColor, + focusedContainerColor = focusedContainerColor, + focusedContentColor = focusedContentColor, + pressedContainerColor = pressedContainerColor, + pressedContentColor = pressedContentColor, + selectedContainerColor = selectedContainerColor, + selectedContentColor = selectedContentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = + if (doesNavigationDrawerHaveFocus) { + disabledContentColor + } else { + disabledInactiveContentColor + }, + focusedSelectedContainerColor = focusedSelectedContainerColor, + focusedSelectedContentColor = focusedSelectedContentColor, + pressedSelectedContainerColor = pressedSelectedContainerColor, + pressedSelectedContentColor = pressedSelectedContentColor, + ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt deleted file mode 100644 index 7fc97366..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext -import androidx.media3.common.Player -import androidx.media3.common.Player.Listener -import com.github.damontecres.wholphin.ui.findActivity -import com.github.damontecres.wholphin.ui.keepScreenOn - -/** - * Starts a [Player.Listener] that ensures the screen stays on without a screen saber during playback - * - * This will clean up the listener when disposed - */ -@Composable -fun AmbientPlayerListener(player: Player) { - val context = LocalContext.current - DisposableEffect(player) { - val listener = - object : Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - context.findActivity()?.keepScreenOn(isPlaying) - } - } - player.addListener(listener) - onDispose { - player.removeListener(listener) - context.findActivity()?.keepScreenOn(false) - } - } -} 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/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index f10a0758..5ed82729 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -20,6 +20,7 @@ class PlaybackKeyHandler( private val skipWithLeftRight: Boolean, private val seekBack: Duration, private val seekForward: Duration, + private val getDurationMs: () -> Long, private val controllerViewState: ControllerViewState, private val updateSkipIndicator: (Long) -> Unit, private val skipBackOnResume: Duration?, @@ -28,15 +29,22 @@ class PlaybackKeyHandler( private val onStop: () -> Unit, private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit, ) { + private var leftHandledByRepeat = false + private var rightHandledByRepeat = false + fun onKeyEvent(it: KeyEvent): Boolean { if (it.type == KeyEventType.KeyUp) onInteraction.invoke() - var result = true if (!controlsEnabled) { - result = false + return false + } else if (handleHoldSkip(it)) { + return true } else if (it.type != KeyEventType.KeyUp) { - result = false - } else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { + return false + } + + var result = true + if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { if (!controllerViewState.controlsVisible) { if (skipWithLeftRight && isSkipBack(it)) { updateSkipIndicator(-seekBack.inWholeMilliseconds) @@ -111,4 +119,84 @@ class PlaybackKeyHandler( } return result } + + private fun handleHoldSkip(event: KeyEvent): Boolean { + if ( + controllerViewState.controlsVisible || + !skipWithLeftRight || + (!isSkipBack(event) && !isSkipForward(event)) + ) { + return false + } + + val isBack = isSkipBack(event) + return when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { + setHandledByRepeat(isBack = isBack, handled = false) + return true + } + val multiplier = + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + durationMs = normalizedDurationMs(), + ) + setHandledByRepeat(isBack = isBack, handled = true) + seekWithMultiplier(isBack = isBack, multiplier = multiplier) + } else { + setHandledByRepeat(isBack = isBack, handled = false) + } + true + } + + KeyEventType.KeyUp -> { + if (!handledByRepeat(isBack = isBack)) { + seekWithMultiplier(isBack = isBack, multiplier = 1) + } + setHandledByRepeat(isBack = isBack, handled = false) + true + } + + else -> { + false + } + } + } + + private fun seekWithMultiplier( + isBack: Boolean, + multiplier: Int, + ) { + if (isBack) { + val skipDuration = seekBack * multiplier + player.seekBack(skipDuration) + updateSkipIndicator(-skipDuration.inWholeMilliseconds) + } else { + val skipDuration = seekForward * multiplier + player.seekForward(skipDuration) + updateSkipIndicator(skipDuration.inWholeMilliseconds) + } + } + + private fun setHandledByRepeat( + isBack: Boolean, + handled: Boolean, + ) { + if (isBack) { + leftHandledByRepeat = handled + } else { + rightHandledByRepeat = handled + } + } + + private fun handledByRepeat(isBack: Boolean): Boolean = + if (isBack) { + leftHandledByRepeat + } else { + rightHandledByRepeat + } + + private fun normalizedDurationMs(): Long = getDurationMs().coerceAtLeast(0L) } 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 99313af2..0886590e 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( @@ -554,26 +612,28 @@ fun Controller( fontSize = subtitleTextSize, ) } - if (showClock) { - var remaining by remember { mutableStateOf((playerControls.duration - playerControls.currentPosition).milliseconds) } - LaunchedEffect(playerControls) { - while (isActive) { - remaining = - (playerControls.duration - playerControls.currentPosition).milliseconds - delay(1.seconds) - } + + var endTimeStr by remember { mutableStateOf("...") } + LaunchedEffect(playerControls) { + while (isActive) { + val remaining = + (playerControls.duration - playerControls.currentPosition) + .div(playerControls.playbackParameters.speed) + .toLong() + .milliseconds + val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) + endTimeStr = TimeFormatter.format(endTime) + delay(1.seconds) } - val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - val endTimeStr = TimeFormatter.format(endTime) - Text( - text = "Ends $endTimeStr", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(end = 32.dp), - ) } + Text( + text = "Ends $endTimeStr", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(end = 32.dp), + ) } } // TODO need to move these up a level? 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 8e8744fd..a4ce04e2 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 @@ -37,6 +37,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -75,6 +76,7 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize @@ -90,7 +92,6 @@ import io.github.peerless2012.ass.media.widget.AssSubtitleView 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 @@ -168,8 +169,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) } @@ -184,6 +184,7 @@ fun PlaybackPageContent( LaunchedEffect(player) { if (playerBackend == PlayerBackend.MPV) { scope.launch(Dispatchers.IO + ExceptionHandler()) { + // MPV can't play HDR, so always use regular settings preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( configuration, density, @@ -192,7 +193,6 @@ fun PlaybackPageContent( } } - AmbientPlayerListener(player) var contentScale by remember(playerBackend) { mutableStateOf( if (playerBackend == PlayerBackend.MPV) { @@ -242,6 +242,7 @@ fun PlaybackPageContent( skipWithLeftRight = true, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + getDurationMs = { player.duration.coerceAtLeast(0L) }, controllerViewState = controllerViewState, updateSkipIndicator = updateSkipIndicator, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, @@ -306,10 +307,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( @@ -408,18 +409,31 @@ fun PlaybackPageContent( onClickPlaylist = { viewModel.playItemInPlaylist(it) }, - currentSegment = currentSegment, + currentSegment = currentSegment?.segment, showClock = preferences.appPreferences.interfacePreferences.showClock, ) } + val subtitleSettings = + remember(mediaInfo) { + Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}") + if (mediaInfo?.videoStream?.hdr == true) { + preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences + } else { + preferences.appPreferences.interfacePreferences.subtitlesPreferences + } + } + val subtitleImageOpacity = + remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } + // Subtitles if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) + val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } AndroidView( factory = { context -> SubtitleView(context).apply { - preferences.appPreferences.interfacePreferences.subtitlesPreferences.let { + subtitleSettings.let { setStyle(it.toSubtitleStyle()) setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setBottomPaddingFraction(it.margin.toFloat() / 100f) @@ -443,10 +457,8 @@ fun PlaybackPageContent( }, update = { it.setCues(cues) - Media3SubtitleOverride( - preferences.appPreferences.interfacePreferences.subtitlesPreferences - .calculateEdgeSize(density), - ).apply(it) + Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) + .apply(it) it.children.firstOrNull { it is AssSubtitleView }?.let { (it as? AssSubtitleView)?.apply { Timber.v("Resize: $playerSize") @@ -466,7 +478,8 @@ fun PlaybackPageContent( Modifier .fillMaxSize(maxSize) .align(Alignment.TopCenter) - .background(Color.Transparent), + .background(Color.Transparent) + .ifElse(isImageSubtitles, Modifier.alpha(subtitleImageOpacity)), ) } } @@ -484,13 +497,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 7e06f5ee..4f652360 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,14 +41,17 @@ 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 import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.RefreshRateService +import com.github.damontecres.wholphin.services.ScreensaverService 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 +60,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 @@ -96,6 +98,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 @@ -138,6 +141,8 @@ class PlaybackViewModel private val refreshRateService: RefreshRateService, val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, + private val imageUrlService: ImageUrlService, + private val screensaverService: ScreensaverService, @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, @@ -165,8 +170,7 @@ class PlaybackViewModel val currentMediaInfo = MutableLiveData<CurrentMediaInfo>(CurrentMediaInfo.EMPTY) val currentPlayback = MutableStateFlow<CurrentPlayback?>(null) val currentItemPlayback = MutableLiveData<ItemPlayback>() - val currentSegment = EqualityMutableLiveData<MediaSegmentDto?>(null) - private val autoSkippedSegments = mutableSetOf<UUID>() + val currentSegment = MutableStateFlow<MediaSegmentState?>(null) val subtitleCues = MutableLiveData<List<Cue>>(listOf()) @@ -188,7 +192,10 @@ class PlaybackViewModel init { viewModelScope.launchIO { - addCloseable { disconnectPlayer() } + addCloseable { + screensaverService.keepScreenOn(false) + disconnectPlayer() + } init() } } @@ -675,7 +682,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) { @@ -960,15 +975,19 @@ class PlaybackViewModel } } + // Variables for tracking segment state private var segmentJob: Job? = null + private val autoSkippedSegments = mutableSetOf<UUID>() + private val outroShownSegments = mutableSetOf<UUID>() /** * Cancels listening for segments and clears current segment state */ - private suspend fun resetSegmentState() { + private fun resetSegmentState() { segmentJob?.cancel() autoSkippedSegments.clear() - currentSegment.setValueOnMain(null) + outroShownSegments.clear() + currentSegment.value = null } /** @@ -991,20 +1010,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}") @@ -1024,13 +1050,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 -> { @@ -1049,6 +1083,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 { @@ -1376,6 +1432,10 @@ class PlaybackViewModel } } } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + screensaverService.keepScreenOn(isPlaying) + } } data class PlayerState( @@ -1383,3 +1443,8 @@ data class PlayerState( val backend: PlayerBackend, val assHandler: AssHandler?, ) + +data class MediaSegmentState( + val segment: MediaSegmentDto, + val interacted: Boolean, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt new file mode 100644 index 00000000..6925d5cc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.ui.playback + +internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 8 + +/** + * Shared seek acceleration profile for hold-to-seek behavior. + * Keep this in sync anywhere directional key repeat seeking is handled. + */ +fun calculateSeekAccelerationMultiplier( + repeatCount: Int, + durationMs: Long, +): Int { + if (repeatCount <= 0 || durationMs <= 0L) return 1 + + // Repeat cadence varies by device. Scaling down by 3 keeps ramp-up closer to multi-second holds. + val scaledRepeatCount = repeatCount / 3 + if (scaledRepeatCount <= 0) return 1 + + val durationMinutes = durationMs / 60_000L + return when { + durationMinutes < 30 -> { + if (scaledRepeatCount < 30) 1 else 2 + } + + durationMinutes < 90 -> { + when { + scaledRepeatCount < 13 -> 1 + scaledRepeatCount < 50 -> 2 + scaledRepeatCount < 75 -> 3 + else -> 4 + } + } + + durationMinutes < 150 -> { + when { + scaledRepeatCount < 20 -> 1 + scaledRepeatCount < 40 -> 2 + scaledRepeatCount < 60 -> 4 + else -> 6 + } + } + + else -> { + when { + scaledRepeatCount < 20 -> 1 + scaledRepeatCount < 40 -> 3 + scaledRepeatCount < 60 -> 6 + else -> 10 + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt index d1db4144..92c6114b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.ui.handleDPadKeyEvents import kotlinx.coroutines.FlowPreview import kotlin.time.Duration @@ -80,15 +79,16 @@ fun SteppedSeekBarImpl( enabled = enabled, progress = progressToUse, bufferedProgress = bufferedProgress, - onLeft = { + durationMs = durationMs, + onLeft = { multiplier -> controllerViewState.pulseControls() - seekProgress = (progressToUse - offset).coerceAtLeast(0f) + seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f) hasSeeked = true seek(seekProgress) }, - onRight = { + onRight = { multiplier -> controllerViewState.pulseControls() - seekProgress = (progressToUse + offset).coerceAtMost(1f) + seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f) hasSeeked = true seek(seekProgress) }, @@ -126,16 +126,19 @@ fun IntervalSeekBarImpl( enabled = enabled, progress = (progressToUse.toDouble() / durationMs).toFloat(), bufferedProgress = bufferedProgress, - onLeft = { + durationMs = durationMs, + onLeft = { multiplier -> controllerViewState.pulseControls() - seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L) + seekPositionMs = + (progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L) hasSeeked = true onSeek(seekPositionMs) }, - onRight = { + onRight = { multiplier -> controllerViewState.pulseControls() seekPositionMs = - (progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs) + (progressToUse + seekForward.inWholeMilliseconds * multiplier) + .coerceAtMost(durationMs) hasSeeked = true onSeek(seekPositionMs) }, @@ -148,8 +151,9 @@ fun IntervalSeekBarImpl( fun SeekBarDisplay( progress: Float, bufferedProgress: Float, - onLeft: () -> Unit, - onRight: () -> Unit, + durationMs: Long, + onLeft: (Int) -> Unit, + onRight: (Int) -> Unit, interactionSource: MutableInteractionSource, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -158,14 +162,13 @@ fun SeekBarDisplay( val onSurface = MaterialTheme.colorScheme.onSurface val isFocused by interactionSource.collectIsFocusedAsState() + var leftHandledByRepeat by remember { mutableStateOf(false) } + var rightHandledByRepeat by remember { mutableStateOf(false) } val animatedIndicatorHeight by animateDpAsState( targetValue = 6.dp.times((if (isFocused) 2f else 1f)), ) - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) { Canvas( modifier = Modifier @@ -173,24 +176,71 @@ fun SeekBarDisplay( .height(animatedIndicatorHeight) .padding(horizontal = 4.dp) .onPreviewKeyEvent { event -> - val trigger = - event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0 when (event.nativeKeyEvent.keyCode) { KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> { - if (trigger) onLeft.invoke() + when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + leftHandledByRepeat = true + onLeft.invoke( + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount, + durationMs = durationMs, + ), + ) + } else { + leftHandledByRepeat = false + } + } + + KeyEventType.KeyUp -> { + if (!leftHandledByRepeat) { + onLeft.invoke(1) + } + leftHandledByRepeat = false + } + + else -> { + return@onPreviewKeyEvent false + } + } return@onPreviewKeyEvent true } KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> { - if (trigger) onRight.invoke() + when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + rightHandledByRepeat = true + onRight.invoke( + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount, + durationMs = durationMs, + ), + ) + } else { + rightHandledByRepeat = false + } + } + + KeyEventType.KeyUp -> { + if (!rightHandledByRepeat) { + onRight.invoke(1) + } + rightHandledByRepeat = false + } + + else -> { + return@onPreviewKeyEvent false + } + } return@onPreviewKeyEvent true } } false - }.handleDPadKeyEvents( - onLeft = onLeft, - onRight = onRight, - ).focusable(enabled = enabled, interactionSource = interactionSource), + }.focusable(enabled = enabled, interactionSource = interactionSource), onDraw = { val yOffset = size.height.div(2) drawLine( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index 9b9bcf9c..1762f916 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import java.io.File -import java.util.SortedSet @Suppress("UNCHECKED_CAST") @Composable @@ -226,7 +225,7 @@ fun <T> ComposablePreference( } is AppMultiChoicePreference<*, *> -> { - val values = stringArrayResource(preference.displayValues).toSortedSet() + val values = stringArrayResource(preference.displayValues) val summary = preference.summary?.let { stringResource(it) } ?: preference.summary(context, value) @@ -237,12 +236,15 @@ fun <T> ComposablePreference( list } MultiChoicePreference( - possibleValues = values as SortedSet<Any>, + possibleValues = preference.allValues, selectedValues = selectedValues, title = title, summary = summary, onValueChange = { - onValueChange.invoke(selectedValues.toList() as T) + onValueChange.invoke(it.toList() as T) + }, + valueDisplay = { index, _ -> + Text(values[index]) }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt index a4686b10..bb34207f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt @@ -19,12 +19,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup fun <T> MultiChoicePreference( title: String, summary: String?, - possibleValues: Set<T>, + possibleValues: List<T>, selectedValues: Set<T>, onValueChange: (List<T>) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - valueDisplay: @Composable (item: T) -> Unit = { Text(it.toString()) }, + valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) }, ) { // val values = stringArrayResource(preference.displayValues).toList() // val summary = @@ -59,7 +59,7 @@ fun <T> MultiChoicePreference( items = possibleValues.mapIndexed { index, item -> DialogItem( - headlineContent = { valueDisplay.invoke(item) }, + headlineContent = { valueDisplay.invoke(index, item) }, trailingContent = { Switch( checked = selectedValues.contains(item), 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<NavDrawerItem, Boolean>, + ) { + items.map { (item, pinned) -> + NavDrawerPin(item.id, item.name(context), pinned, item) + } + } + } +} + +enum class MoveDirection { + UP, + DOWN, +} + +private fun <T> List<T>.move( + direction: MoveDirection, + index: Int, +): List<T> = + toMutableList().apply { + if (direction == MoveDirection.DOWN) { + val down = this[index] + val up = this[index + 1] + set(index, up) + set(index + 1, down) + } else { + val up = this[index] + val down = this[index - 1] + set(index - 1, up) + set(index, down) + } + } + +@Composable +fun NavDrawerPreference( + title: String, + summary: String?, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + viewModel: NavDrawerPreferencesViewModel = hiltViewModel(), +) { + val items by viewModel.state.collectAsState() + var showDialog by remember { mutableStateOf(false) } + ClickPreference( + title = title, + summary = summary, + onClick = { showDialog = true }, + interactionSource = interactionSource, + modifier = modifier, + ) + if (showDialog) { + NavDrawerPreferenceDialog( + items = items, + onDismissRequest = { + viewModel.save() + showDialog = false + }, + onClick = { index -> + val newItems = + items.toMutableList().apply { + set(index, items[index].let { it.copy(pinned = !it.pinned) }) + } + viewModel.update(newItems) + }, + onMoveUp = { index -> + viewModel.update(items.move(MoveDirection.UP, index)) + }, + onMoveDown = { index -> + viewModel.update(items.move(MoveDirection.DOWN, index)) + }, + ) + } +} + +@Composable +fun NavDrawerPreferenceDialog( + items: List<NavDrawerPin>, + onDismissRequest: () -> Unit, + onClick: (Int) -> Unit, + onMoveUp: (Int) -> Unit, + onMoveDown: (Int) -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + elevation = 3.dp, + ) { + Column( + modifier = + Modifier + .padding(16.dp), + ) { + Text( + text = stringResource(R.string.nav_drawer_pins), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp), + ) + val listState = rememberLazyListState() + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + NavDrawerPreferenceListItem( + title = item.title, + pinned = item.pinned, + moveUpAllowed = index > 0, + moveDownAllowed = index < items.lastIndex, + onClick = { onClick.invoke(index) }, + onMoveUp = { onMoveUp.invoke(index) }, + onMoveDown = { onMoveDown.invoke(index) }, + modifier = Modifier.animateItem(), + ) + } + } + } + } +} + +@Composable +fun NavDrawerPreferenceListItem( + title: String, + pinned: Boolean, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + onClick: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 40.dp, max = 88.dp), + ) { + ListItem( + selected = false, + headlineContent = { + Text( + text = title, + ) + }, + trailingContent = { + Switch( + checked = pinned, + onCheckedChange = { + onClick.invoke() + }, + ) + }, + onClick = onClick, + modifier = Modifier.weight(1f), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.wrapContentWidth(), + ) { + MoveButton(R.string.fa_caret_up, moveUpAllowed, onMoveUp) + MoveButton(R.string.fa_caret_down, moveDownAllowed, onMoveDown) + } + } + } +} + +@Composable +private fun MoveButton( + @StringRes icon: Int, + enabled: Boolean, + onClick: () -> Unit, +) = Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(32.dp), +) { + Text( + text = stringResource(icon), + fontSize = 16.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) +} + +@PreviewTvSpec +@Composable +fun NavDrawerPreferenceListItemPreview() { + WholphinTheme { + NavDrawerPreferenceListItem( + title = "Movies", + pinned = true, + moveUpAllowed = true, + moveDownAllowed = true, + onClick = {}, + onMoveUp = {}, + onMoveDown = { }, + modifier = Modifier.width(360.dp), + ) + } +} + +@HiltViewModel +class NavDrawerPreferencesViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val preferenceDataStore: DataStore<AppPreferences>, + val navigationManager: NavigationManager, + val backdropService: BackdropService, + private val rememberTabManager: RememberTabManager, + private val serverRepository: ServerRepository, + private val navDrawerService: NavDrawerService, + private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, + ) : ViewModel() { + val state = MutableStateFlow<List<NavDrawerPin>>(listOf()) + + init { + viewModelScope.launchDefault { + val state = navDrawerService.state.value + val user = serverRepository.currentUser.value + val seerr = seerrServerRepository.active.firstOrNull() + if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) { + return@launchDefault + } + val navDrawerPins = + withContext(Dispatchers.IO) { + serverPreferencesDao + .getNavDrawerPinnedItems(user) + .associateBy { it.itemId } + } + val allItems = state.let { it.items + it.moreItems } + val pins = + allItems + .sortedBy { + navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE + }.mapNotNull { + if (!seerr && it is NavDrawerItem.Discover) { + null + } else { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + NavDrawerPin( + it.id, + it.name(context), + pinned == NavPinType.PINNED, + it, + ) + } + } + this@NavDrawerPreferencesViewModel.state.value = pins + } + } + + fun update(items: List<NavDrawerPin>) { + state.update { items } + } + + fun save() { + viewModelScope.launchIO(ExceptionHandler(true)) { + serverRepository.currentUser.value?.let { user -> + serverRepository.currentUserDto.value?.let { userDto -> + if (user.id == userDto.id) { + val toSave = + state.value.mapIndexed { index, item -> + NavDrawerPinnedItem( + user.rowId, + item.id, + if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED, + index, + ) + } + serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) + navDrawerService.updateNavDrawer(user, userDto) + } else { + throw IllegalStateException("User IDs do not match") + } + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index f84d3b89..454a0266 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -2,21 +2,20 @@ package com.github.damontecres.wholphin.ui.preferences import androidx.annotation.StringRes import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences import kotlinx.serialization.Serializable /** * A group of preferences */ -data class PreferenceGroup( +data class PreferenceGroup<T>( @param:StringRes val title: Int, - val preferences: List<AppPreference<AppPreferences, out Any?>>, - val conditionalPreferences: List<ConditionalPreferences> = listOf(), + val preferences: List<AppPreference<T, out Any?>>, + val conditionalPreferences: List<ConditionalPreferences<T>> = listOf(), ) -data class ConditionalPreferences( - val condition: (AppPreferences) -> Boolean, - val preferences: List<AppPreference<AppPreferences, out Any?>>, +data class ConditionalPreferences<T>( + val condition: (T) -> Boolean, + val preferences: List<AppPreference<T, out Any?>>, ) /** @@ -34,10 +33,9 @@ sealed interface PreferenceValidation { enum class PreferenceScreenOption { BASIC, ADVANCED, - USER_INTERFACE, - SUBTITLES, EXO_PLAYER, MPV, + SCREENSAVER, ; companion object { 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 8fedf3b6..729e7085 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 @@ -48,10 +50,12 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences -import com.github.damontecres.wholphin.preferences.uiPreferences +import com.github.damontecres.wholphin.preferences.screensaverPreferences 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 @@ -60,7 +64,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings -import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.setup.UpdateViewModel import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel @@ -91,10 +94,10 @@ fun PreferencesContent( val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } - val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } - val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) + val seerrConnection by viewModel.seerrConnection.collectAsState() var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) } + var showQuickConnectDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { @@ -125,19 +128,17 @@ fun PreferencesContent( when (preferenceScreenOption) { PreferenceScreenOption.BASIC -> basicPreferences PreferenceScreenOption.ADVANCED -> advancedPreferences - PreferenceScreenOption.USER_INTERFACE -> uiPreferences - PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences PreferenceScreenOption.MPV -> MpvPreferences + PreferenceScreenOption.SCREENSAVER -> screensaverPreferences } val screenTitle = when (preferenceScreenOption) { PreferenceScreenOption.BASIC -> R.string.settings PreferenceScreenOption.ADVANCED -> R.string.advanced_settings - PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface - PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options PreferenceScreenOption.MPV -> R.string.mpv_options + PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings } var visible by remember { mutableStateOf(false) } @@ -171,291 +172,339 @@ 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<AppPreferences, Any> + 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<AppPreferences, Any> + 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, + ) + } + + ScreensaverPreference.Start -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.screensaverService.start() + }, + 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), + ), + ) + } } } } @@ -478,11 +527,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() @@ -505,13 +554,67 @@ fun PreferencesContent( currentUsername = currentUser?.name, status = status, onSubmit = seerrVm::submitServer, + onResetStatus = seerrVm::resetStatus, onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, ) } + 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 @@ -526,9 +629,9 @@ fun PreferencesPage( when (preferenceScreenOption) { PreferenceScreenOption.BASIC, PreferenceScreenOption.ADVANCED, - PreferenceScreenOption.USER_INTERFACE, PreferenceScreenOption.EXO_PLAYER, PreferenceScreenOption.MPV, + PreferenceScreenOption.SCREENSAVER, -> { PreferencesContent( initialPreferences, @@ -539,12 +642,6 @@ fun PreferencesPage( .align(Alignment.TopEnd), ) } - - PreferenceScreenOption.SUBTITLES -> { - SubtitleStylePage( - initialPreferences, - ) - } } } } @@ -555,10 +652,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..58f9a5e3 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,32 +2,26 @@ 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 import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ScreensaverService 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 @@ -42,64 +36,25 @@ class PreferencesViewModel val preferenceDataStore: DataStore<AppPreferences>, val navigationManager: NavigationManager, val backdropService: BackdropService, + val screensaverService: ScreensaverService, private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, - private val navDrawerItemRepository: NavDrawerItemRepository, - private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, private val clientInfo: ClientInfo, ) : ViewModel(), RememberTabManager by rememberTabManager { - private lateinit var allNavDrawerItems: List<NavDrawerItem> - val navDrawerPins = MutableLiveData<Map<NavDrawerItem, Boolean>>(mapOf()) - val currentUser get() = serverRepository.currentUser - val seerrEnabled = - seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser -> - seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId - } + val seerrConnection = seerrServerRepository.connection + + private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending) + val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus init { viewModelScope.launchIO { serverRepository.currentUser.value?.let { user -> - allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems() - val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) - val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } - this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) - } - } - } - - fun updatePins(newSelectedItems: List<NavDrawerItem>) { - viewModelScope.launchIO(ExceptionHandler(true)) { - serverRepository.currentUser.value?.let { user -> - val disabledItems = - mutableListOf<NavDrawerItem>().apply { - addAll(allNavDrawerItems) - removeAll(newSelectedItems) - } - val enabledItems = newSelectedItems.toSet() - val toSave = - disabledItems.map { - NavDrawerPinnedItem( - user.rowId, - it.id, - NavPinType.UNPINNED, - ) - } + - enabledItems.map { - NavDrawerPinnedItem( - user.rowId, - it.id, - NavPinType.PINNED, - ) - } - serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) - val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) - val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } - this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) +// fetchNavDrawerPins(user) } } } @@ -123,6 +78,27 @@ class PreferencesViewModel } } + fun resetQuickConnectStatus() { + _quickConnectStatus.value = LoadingState.Pending + } + + fun authorizeQuickConnect(code: String) { + viewModelScope.launchIO { + _quickConnectStatus.value = LoadingState.Loading + try { + val success = serverRepository.authorizeQuickConnect(code) + _quickConnectStatus.value = + if (success) { + LoadingState.Success + } else { + LoadingState.Error("Authorization failed") + } + } catch (e: Exception) { + _quickConnectStatus.value = LoadingState.Error(e) + } + } + } + companion object { suspend fun resetSubtitleSettings(appPreferences: DataStore<AppPreferences>) { appPreferences.updateData { 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/preferences/SliderPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt index 56c15f7f..ce686708 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt @@ -65,7 +65,7 @@ fun SliderPreference( } Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -77,7 +77,6 @@ fun SliderPreference( max = preference.max, interval = preference.interval, onChange = onChange, - color = MaterialTheme.colorScheme.border, enableWrapAround = false, interactionSource = interactionSource, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt new file mode 100644 index 00000000..8abc6dbe --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt @@ -0,0 +1,195 @@ +package com.github.damontecres.wholphin.ui.preferences.subtitle + +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +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.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.resetSubtitles +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.preferences.ClickPreference +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup +import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation +import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.launch + +@Composable +fun SubtitlePreferencesContent( + title: String, + preferences: SubtitlePreferences, + prefList: List<PreferenceGroup<SubtitlePreferences>>, + onPreferenceChange: suspend (SubtitlePreferences) -> Unit, + modifier: Modifier = Modifier, + viewModel: PreferencesViewModel = hiltViewModel(), + onFocus: (Int, Int) -> Unit = { _, _ -> }, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } + val state = rememberLazyListState() + + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + // Forces the animated to trigger + visible = true + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn() + slideInHorizontally { it / 2 }, + exit = fadeOut() + slideOutHorizontally { it / 2 }, + modifier = modifier, + ) { + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), + ) { + stickyHeader { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + 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<SubtitlePreferences, Any> + item { + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) + onFocus.invoke(groupIndex, prefIndex) + } + } + when (pref) { + SubtitleSettings.Reset -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + scope.launch(ExceptionHandler()) { + val newValue = + SubtitlePreferences + .newBuilder() + .apply { resetSubtitles() } + .build() + onPreferenceChange.invoke(newValue) + } + }, + 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()) { + onPreferenceChange.invoke( + pref.setter( + preferences, + newValue, + ), + ) + } + } + } + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt index 506649d5..b7f3d764 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt @@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.ui.preferences.subtitle import android.content.res.Configuration import android.graphics.Typeface +import android.os.Build +import android.view.Display import androidx.annotation.OptIn import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -13,14 +15,14 @@ import androidx.media3.ui.CaptionStyleCompat import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppClickablePreference -import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppDestinationPreference import com.github.damontecres.wholphin.preferences.AppSliderPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.BackgroundStyle import com.github.damontecres.wholphin.preferences.EdgeStyle import com.github.damontecres.wholphin.preferences.SubtitlePreferences -import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.util.mpv.MPVLib import com.github.damontecres.wholphin.util.mpv.setPropertyColor @@ -28,18 +30,17 @@ import timber.log.Timber object SubtitleSettings { val FontSize = - AppSliderPreference<AppPreferences>( + AppSliderPreference<SubtitlePreferences>( title = R.string.font_size, defaultValue = 24, min = 8, max = 70, interval = 2, getter = { - it.interfacePreferences.subtitlesPreferences.fontSize - .toLong() + it.fontSize.toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontSize = value.toInt() } + prefs.update { fontSize = value.toInt() } }, summarizer = { value -> value?.toString() }, ) @@ -59,12 +60,12 @@ object SubtitleSettings { ) val FontColor = - AppChoicePreference<AppPreferences, Color>( + AppChoicePreference<SubtitlePreferences, Color>( title = R.string.font_color, defaultValue = Color.White, - getter = { Color(it.interfacePreferences.subtitlesPreferences.fontColor) }, + getter = { Color(it.fontColor) }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontColor = value.toArgb().and(0x00FFFFFF) } + prefs.update { fontColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -75,49 +76,49 @@ object SubtitleSettings { ) val FontBold = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference<SubtitlePreferences>( title = R.string.bold_font, defaultValue = false, - getter = { it.interfacePreferences.subtitlesPreferences.fontBold }, + getter = { it.fontBold }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontBold = value } + prefs.update { fontBold = value } }, ) val FontItalic = - AppSwitchPreference<AppPreferences>( + AppSwitchPreference<SubtitlePreferences>( title = R.string.italic_font, defaultValue = false, - getter = { it.interfacePreferences.subtitlesPreferences.fontItalic }, + getter = { it.fontItalic }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontItalic = value } + prefs.update { fontItalic = value } }, ) val FontOpacity = - AppSliderPreference<AppPreferences>( + AppSliderPreference<SubtitlePreferences>( title = R.string.font_opacity, defaultValue = 100, min = 10, max = 100, interval = 10, getter = { - it.interfacePreferences.subtitlesPreferences.fontOpacity + it.fontOpacity .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontOpacity = value.toInt() } + prefs.update { fontOpacity = value.toInt() } }, summarizer = { value -> value?.let { "$it%" } }, ) val EdgeStylePref = - AppChoicePreference<AppPreferences, EdgeStyle>( + AppChoicePreference<SubtitlePreferences, EdgeStyle>( title = R.string.edge_style, defaultValue = EdgeStyle.EDGE_SOLID, - getter = { it.interfacePreferences.subtitlesPreferences.edgeStyle }, + getter = { it.edgeStyle }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { edgeStyle = value } + prefs.update { edgeStyle = value } }, displayValues = R.array.subtitle_edge, indexToValue = { EdgeStyle.forNumber(it) }, @@ -125,12 +126,12 @@ object SubtitleSettings { ) val EdgeColor = - AppChoicePreference<AppPreferences, Color>( + AppChoicePreference<SubtitlePreferences, Color>( title = R.string.edge_color, defaultValue = Color.Black, - getter = { Color(it.interfacePreferences.subtitlesPreferences.edgeColor) }, + getter = { Color(it.edgeColor) }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { edgeColor = value.toArgb().and(0x00FFFFFF) } + prefs.update { edgeColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -141,29 +142,29 @@ object SubtitleSettings { ) val EdgeThickness = - AppSliderPreference<AppPreferences>( + AppSliderPreference<SubtitlePreferences>( title = R.string.edge_size, defaultValue = 4, min = 1, max = 32, interval = 1, getter = { - it.interfacePreferences.subtitlesPreferences.edgeThickness + it.edgeThickness .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { edgeThickness = value.toInt() } + prefs.update { edgeThickness = value.toInt() } }, summarizer = { value -> value?.let { "${it / 2.0}" } }, ) val BackgroundColor = - AppChoicePreference<AppPreferences, Color>( + AppChoicePreference<SubtitlePreferences, Color>( title = R.string.background_color, defaultValue = Color.Transparent, - getter = { Color(it.interfacePreferences.subtitlesPreferences.backgroundColor) }, + getter = { Color(it.backgroundColor) }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { backgroundColor = value.toArgb().and(0x00FFFFFF) } + prefs.update { backgroundColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -174,30 +175,30 @@ object SubtitleSettings { ) val BackgroundOpacity = - AppSliderPreference<AppPreferences>( + AppSliderPreference<SubtitlePreferences>( title = R.string.background_opacity, defaultValue = 50, min = 10, max = 100, interval = 10, getter = { - it.interfacePreferences.subtitlesPreferences.backgroundOpacity + it.backgroundOpacity .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { backgroundOpacity = value.toInt() } + prefs.update { backgroundOpacity = value.toInt() } }, summarizer = { value -> value?.let { "$it%" } }, ) val BackgroundStylePref = - AppChoicePreference<AppPreferences, BackgroundStyle>( + AppChoicePreference<SubtitlePreferences, BackgroundStyle>( title = R.string.background_style, defaultValue = BackgroundStyle.BG_NONE, - getter = { it.interfacePreferences.subtitlesPreferences.backgroundStyle }, + getter = { it.backgroundStyle }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { backgroundStyle = value } + prefs.update { backgroundStyle = value } }, displayValues = R.array.background_style, indexToValue = { BackgroundStyle.forNumber(it) }, @@ -205,29 +206,51 @@ object SubtitleSettings { ) val Margin = - AppSliderPreference<AppPreferences>( + AppSliderPreference<SubtitlePreferences>( title = R.string.subtitle_margin, defaultValue = 8, min = 0, max = 100, interval = 1, getter = { - it.interfacePreferences.subtitlesPreferences.margin + it.margin .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { margin = value.toInt() } + prefs.update { margin = value.toInt() } + }, + summarizer = { value -> value?.let { "$it%" } }, + ) + + val ImageOpacity = + AppSliderPreference<SubtitlePreferences>( + title = R.string.image_subtitle_opacity, + defaultValue = 100, + min = 10, + max = 100, + interval = 5, + getter = { + it.imageSubtitleOpacity.toLong() + }, + setter = { prefs, value -> + prefs.update { imageSubtitleOpacity = value.toInt() } }, summarizer = { value -> value?.let { "$it%" } }, ) val Reset = - AppClickablePreference<AppPreferences>( + AppClickablePreference<SubtitlePreferences>( title = R.string.reset, getter = { }, setter = { prefs, _ -> prefs }, ) + val HdrSettings = + AppDestinationPreference<SubtitlePreferences>( + title = R.string.hdr_subtitle_style, + destination = Destination.SubtitleSettings(true), + ) + val preferences = listOf( PreferenceGroup( @@ -264,11 +287,25 @@ object SubtitleSettings { preferences = listOf( Margin, + ImageOpacity, Reset, ), ), ) + val hdrPreferenceGroup = + listOf( + PreferenceGroup( + title = R.string.hdr, + preferences = + listOf( + HdrSettings, + ), + ), + ) + + fun shouldShowHdr(display: Display): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && display.isHdr + private fun combine( color: Int, opacity: Int, @@ -361,3 +398,5 @@ object SubtitleSettings { MPVLib.setPropertyString("sub-border-style", borderStyle) } } + +inline fun SubtitlePreferences.update(block: SubtitlePreferences.Builder.() -> Unit): SubtitlePreferences = toBuilder().apply(block).build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt index b0bc6426..52b3570a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt @@ -1,5 +1,7 @@ package com.github.damontecres.wholphin.ui.preferences.subtitle +import android.content.pm.ActivityInfo +import android.os.Build import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.foundation.Image @@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -19,9 +22,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -30,20 +37,25 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption -import com.github.damontecres.wholphin.ui.preferences.PreferencesContent +import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.resetSubtitles +import com.github.damontecres.wholphin.preferences.updateInterfacePreferences +import com.github.damontecres.wholphin.ui.findActivity import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle import com.github.damontecres.wholphin.util.Media3SubtitleOverride +import timber.log.Timber @OptIn(UnstableApi::class) @Composable fun SubtitleStylePage( initialPreferences: AppPreferences, + hdrSettings: Boolean, modifier: Modifier = Modifier, viewModel: PreferencesViewModel = hiltViewModel(), ) { + val context = LocalContext.current val density = LocalDensity.current var preferences by remember { mutableStateOf(initialPreferences) } LaunchedEffect(Unit) { @@ -51,8 +63,27 @@ fun SubtitleStylePage( preferences = it } } - val prefs = preferences.interfacePreferences.subtitlesPreferences + val display = LocalView.current.display + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + DisposableEffect(context) { + if (hdrSettings) { + Timber.v("Switching color mode to HDR") + context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_HDR + } + onDispose { + context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT + } + } + } + val prefs = + if (hdrSettings) { + preferences.interfacePreferences.hdrSubtitlesPreferences + } else { + preferences.interfacePreferences.subtitlesPreferences + } var focusedOnMargin by remember { mutableStateOf(false) } + var focusedOnImageOpacity by remember { mutableStateOf(false) } Row( modifier = modifier, @@ -72,7 +103,7 @@ fun SubtitleStylePage( Modifier .fillMaxSize(), ) - if (!focusedOnMargin) { + if (!focusedOnMargin && !focusedOnImageOpacity) { Column( verticalArrangement = Arrangement.SpaceBetween, modifier = @@ -109,7 +140,7 @@ fun SubtitleStylePage( ) } } - } else { + } else if (focusedOnMargin) { // Margin AndroidView( factory = { context -> @@ -129,17 +160,79 @@ fun SubtitleStylePage( Modifier .fillMaxSize(), ) + } else if (focusedOnImageOpacity) { + AndroidView( + factory = { context -> + SubtitleView(context) + }, + update = { + it.setStyle( + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + }.build() + .toSubtitleStyle(), + ) + it.setCues( + listOf( + Cue + .Builder() + .setText("ExoPlayer only:\nImage based subtitles can be dimmed.") + .build(), + ), + ) + }, + modifier = + Modifier + .fillMaxSize() + .alpha(prefs.imageSubtitleOpacity / 100f), + ) } } - PreferencesContent( - initialPreferences = preferences, - preferenceScreenOption = PreferenceScreenOption.SUBTITLES, + val display = LocalView.current.display + val prefList = + remember(hdrSettings, display) { + if (!hdrSettings && SubtitleSettings.shouldShowHdr(display)) { + // If not on HDR page and display is HDR capable, then show the HDR button + SubtitleSettings.preferences + SubtitleSettings.hdrPreferenceGroup + } else { + SubtitleSettings.preferences + } + } + SubtitlePreferencesContent( + title = + if (hdrSettings) { + stringResource(R.string.hdr_subtitle_style) + } else { + stringResource(R.string.subtitle_style) + }, + preferences = + if (hdrSettings) { + preferences.interfacePreferences.hdrSubtitlesPreferences + } else { + preferences.interfacePreferences.subtitlesPreferences + }, + prefList = prefList, + onPreferenceChange = { newSubtitlePrefs -> + viewModel.preferenceDataStore.updateData { + it.updateInterfacePreferences { + if (hdrSettings) { + hdrSubtitlesPreferences = newSubtitlePrefs + } else { + subtitlesPreferences = newSubtitlePrefs + } + } + } + }, onFocus = { groupIndex, prefIndex -> - - focusedOnMargin = - SubtitleSettings.preferences.getOrNull(groupIndex)?.preferences?.getOrNull( - prefIndex, - ) == SubtitleSettings.Margin + val focusedPref = + SubtitleSettings.preferences + .getOrNull(groupIndex) + ?.preferences + ?.getOrNull(prefIndex) + focusedOnMargin = focusedPref == SubtitleSettings.Margin + focusedOnImageOpacity = focusedPref == SubtitleSettings.ImageOpacity }, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index b209ee59..5fd47621 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -25,6 +25,7 @@ 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.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -382,7 +383,10 @@ fun AddServerCard( Surface( onClick = onClick, interactionSource = interactionSource, - modifier = Modifier.size(cardSize), + modifier = + Modifier + .size(cardSize) + .testTag("add_server"), shape = ClickableSurfaceDefaults.shape(shape = CircleShape), colors = ClickableSurfaceDefaults.colors( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index 4902ca06..a4af8580 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -352,6 +353,7 @@ fun SwitchServerContent( ), modifier = Modifier + .testTag("server_url_text") .focusRequester(textBoxFocusRequester) .fillMaxWidth(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index f9e7d0a3..08398a86 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -11,12 +11,15 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -42,6 +45,8 @@ class SwitchServerViewModel val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: SetupNavigationManager, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, ) : ViewModel() { val servers = MutableLiveData<List<JellyfinServer>>(listOf()) val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf()) 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..5fa8c667 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 @@ -4,7 +4,6 @@ import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -62,19 +61,23 @@ 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, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.align(Alignment.CenterHorizontally), ) + val labelWidth = 90.dp Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "URL", - modifier = Modifier.padding(end = 8.dp), + text = stringResource(R.string.url), + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), ) EditTextBox( value = url, @@ -104,8 +107,11 @@ fun AddSeerrServerApiKey( modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "API Key", - modifier = Modifier.padding(end = 8.dp), + text = stringResource(R.string.api_key), + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), ) EditTextBox( value = apiKey, @@ -173,7 +179,7 @@ fun AddSeerrServerUsername( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.align(Alignment.CenterHorizontally), ) val labelWidth = 90.dp Row( 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..d84b98db 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 @@ -1,11 +1,17 @@ package com.github.damontecres.wholphin.ui.setup.seerr +import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +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 @@ -18,6 +24,7 @@ fun AddSeerServerDialog( currentUsername: String?, status: LoadingState, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, + onResetStatus: () -> Unit, onDismissRequest: () -> Unit, ) { var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) } @@ -32,6 +39,7 @@ fun AddSeerServerDialog( -> { BasicDialog( onDismissRequest = { authMethod = null }, + properties = DialogProperties(usePlatformDefaultWidth = false), ) { AddSeerrServerUsername( onSubmit = { url, username, password -> @@ -39,6 +47,7 @@ fun AddSeerServerDialog( }, username = currentUsername ?: "", status = status, + modifier = Modifier.widthIn(min = 320.dp), ) } } @@ -52,6 +61,7 @@ fun AddSeerServerDialog( onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, status = status, + modifier = Modifier.widthIn(min = 320.dp), ) } } @@ -59,7 +69,10 @@ fun AddSeerServerDialog( null -> { ChooseSeerrLoginType( onDismissRequest = onDismissRequest, - onChoose = { authMethod = it }, + onChoose = { + onResetStatus.invoke() + authMethod = it + }, ) } } @@ -71,27 +84,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/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 4f93217a..0f62f12f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -50,63 +50,73 @@ class SwitchSeerrViewModel serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } return@launchIO } - var result: LoadingState = LoadingState.Error("No url") + Timber.v("Urls to try: %s", urls) + val results = mutableMapOf<HttpUrl, LoadingState>() for (url in urls) { Timber.d("Trying %s", url) - result = - try { - seerrServerRepository.testConnection( - authMethod = authMethod, - url = url.toString(), - username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, - passwordOrApiKey = passwordOrApiKey, - ) - } catch (ex: ClientException) { - Timber.w(ex, "ClientException logging in") - if (ex.statusCode == 401 || ex.statusCode == 403) { - showToast(context, "Invalid credentials") - result = LoadingState.Error("Invalid credentials", ex) - break - } else { - LoadingState.Error("Could not connect with URL") - } - } catch (ex: Exception) { - Timber.w(ex, "Exception logging in") - LoadingState.Error(ex) - } - if (result is LoadingState.Success) { - when (authMethod) { - SeerrAuthMethod.LOCAL, - SeerrAuthMethod.JELLYFIN, - -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - authMethod, - username, - passwordOrApiKey, - ) - } - - SeerrAuthMethod.API_KEY -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - passwordOrApiKey, - ) - } - } + try { + seerrServerRepository.testConnection( + authMethod = authMethod, + url = url.toString(), + username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, + passwordOrApiKey = passwordOrApiKey, + ) + results[url] = LoadingState.Success break + } catch (ex: ClientException) { + Timber.w(ex, "ClientException logging in %s", url) + if (ex.statusCode == 401 || ex.statusCode == 403) { + showToast(context, "Invalid credentials") + results[url] = LoadingState.Error("Invalid credentials", ex) + } else { + results[url] = LoadingState.Error("Could not connect with URL") + } + } catch (ex: Exception) { + Timber.w(ex, "ClientException logging in %s", url) + results[url] = LoadingState.Error(ex) } } - if (result is LoadingState.Error) { - showToast(context, "Error: ${result.message}") + val result = results.filter { (url, state) -> state is LoadingState.Success } + if (result.isNotEmpty()) { + val url = result.keys.first() + when (authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + authMethod, + username, + passwordOrApiKey, + ) + } + + SeerrAuthMethod.API_KEY -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + passwordOrApiKey, + ) + } + } + } else { + val message = + results + .map { (url, state) -> + val s = state as? LoadingState.Error + "$url - ${s?.localizedMessage}" + }.joinToString("\n") + showToast(context, "Could not connect") + serverConnectionStatus.update { LoadingState.Error(message) } } - serverConnectionStatus.update { result } } } fun removeServer() { viewModelScope.launchIO { - seerrServerRepository.removeServer() + val result = seerrServerRepository.removeServerForCurrentUser() + if (!result) { + showToast(context, "Could not remove server") + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt new file mode 100644 index 00000000..1b9555c6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt @@ -0,0 +1,219 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import androidx.annotation.OptIn +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Icon +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.launch +import kotlin.time.Duration + +@OptIn(UnstableApi::class) +@Composable +fun ImageControlsOverlay( + slideshowEnabled: Boolean, + slideshowControls: SlideshowControls, + onDismiss: () -> Unit, + isImageClip: Boolean, + onZoom: (Float) -> Unit, + onRotate: (Int) -> Unit, + onReset: () -> Unit, + moreOnClick: () -> Unit, + isPlaying: Boolean, + playPauseOnClick: () -> Unit, + onShowFilterDialogClick: () -> Unit, + bringIntoViewRequester: BringIntoViewRequester?, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + val onFocused = { focusState: FocusState -> + if (focusState.isFocused && bringIntoViewRequester != null) { + scope.launch(ExceptionHandler()) { bringIntoViewRequester.bringIntoView() } + } + } + + LazyRow( + modifier = + modifier + .focusGroup(), + contentPadding = PaddingValues(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + item { + ExpandablePlayButton( + title = if (slideshowEnabled) R.string.stop_slideshow else R.string.play_slideshow, + icon = + painterResource( + if (slideshowEnabled) { + R.drawable.baseline_pause_24 + } else { + R.drawable.baseline_play_arrow_24 + }, + ), + resume = Duration.ZERO, + onClick = { + if (slideshowEnabled) { + slideshowControls.stopSlideshow() + } else { + slideshowControls.startSlideshow() + onDismiss.invoke() + } + }, + modifier = + Modifier + .focusRequester(focusRequester) + .onFocusChanged(onFocused), + ) + } + if (isImageClip) { + item { + Button( + onClick = playPauseOnClick, + modifier = + Modifier + .onFocusChanged(onFocused), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + painter = + painterResource( + if (isPlaying) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24, + ), + contentDescription = null, + ) + } + } + } else { + // Regular image + item { + ExpandableFaButton( + title = R.string.rotate_left, + iconStringRes = R.string.fa_rotate_left, + onClick = { onRotate(-90) }, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + item { + ExpandableFaButton( + title = R.string.rotate_right, + iconStringRes = R.string.fa_rotate_right, + onClick = { onRotate(90) }, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + item { + ExpandableFaButton( + title = R.string.zoom_in, + iconStringRes = R.string.fa_magnifying_glass_plus, + onClick = { onZoom(.15f) }, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + item { + ExpandableFaButton( + title = R.string.zoom_out, + iconStringRes = R.string.fa_magnifying_glass_minus, + onClick = { onZoom(-.15f) }, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + item { + ExpandableFaButton( + title = R.string.reset, + iconStringRes = R.string.fa_arrows_rotate, + onClick = onReset, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + item { + ExpandableFaButton( + title = R.string.filter, + iconStringRes = R.string.fa_sliders, + onClick = onShowFilterDialogClick, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + } + // More button +// item { +// ExpandablePlayButton( +// title = R.string.more, +// resume = Duration.ZERO, +// icon = Icons.Default.MoreVert, +// onClick = { moreOnClick.invoke() }, +// modifier = Modifier.onFocusChanged(onFocused), +// ) +// } + } +} + +@Preview(widthDp = 800) +@Composable +private fun ImageControlsOverlayPreview() { + WholphinTheme { + ImageControlsOverlay( + slideshowEnabled = true, + slideshowControls = + object : SlideshowControls { + override fun startSlideshow() { + } + + override fun stopSlideshow() { + } + }, + isImageClip = false, + onZoom = {}, + onRotate = {}, + onReset = {}, + moreOnClick = {}, + isPlaying = false, + playPauseOnClick = {}, + bringIntoViewRequester = null, + onShowFilterDialogClick = {}, + onDismiss = {}, + modifier = Modifier, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt new file mode 100644 index 00000000..99675dc0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt @@ -0,0 +1,114 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.components.StreamLabel +import com.github.damontecres.wholphin.ui.components.VideoStreamDetails +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import org.jellyfin.sdk.model.api.MediaType + +@OptIn(UnstableApi::class) +@Composable +fun ImageDetailsHeader( + slideshowEnabled: Boolean, + slideshowControls: SlideshowControls, + player: Player, + image: ImageState, + position: Int, + count: Int, + moreOnClick: () -> Unit, + onZoom: (Float) -> Unit, + onRotate: (Int) -> Unit, + onReset: () -> Unit, + onShowFilterDialogClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val scope = rememberCoroutineScope() + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .bringIntoViewRequester(bringIntoViewRequester), + ) { + image.image.title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + } + if (image.image.ui.quickDetails + .isNotNullOrBlank() + ) { + QuickDetails(image.image.ui.quickDetails, null) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StreamLabel("${position + 1} of $count") + if (image.image.data.mediaType == MediaType.VIDEO) { + VideoStreamDetails( + chosenStreams = image.chosenStreams, + numberOfVersions = 0, + ) + } else { + image.image.data.let { + if (it.width != null && it.height != null) { + StreamLabel("${it.width}x${it.height}") + } + } + } + } + OverviewText( + overview = image.image.data.overview ?: "", + maxLines = 3, + onClick = {}, + modifier = Modifier.fillMaxWidth(.75f), + ) + val playPauseState = rememberPlayPauseButtonState(player) + ImageControlsOverlay( + slideshowEnabled = slideshowEnabled, + slideshowControls = slideshowControls, + isImageClip = image.image.data.mediaType == MediaType.VIDEO, + bringIntoViewRequester = bringIntoViewRequester, + onZoom = onZoom, + onRotate = onRotate, + onReset = onReset, + moreOnClick = moreOnClick, + playPauseOnClick = playPauseState::onClick, + isPlaying = playPauseState.showPlay, + onShowFilterDialogClick = onShowFilterDialogClick, + onDismiss = {}, + modifier = + Modifier + .fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt new file mode 100644 index 00000000..d07f054a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt @@ -0,0 +1,296 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import android.view.Gravity +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.VideoFilter +import com.github.damontecres.wholphin.ui.components.SliderBar +import com.github.damontecres.wholphin.ui.components.SliderColors +import com.github.damontecres.wholphin.ui.theme.WholphinTheme + +const val DRAG_THROTTLE_DELAY = 50L + +@Composable +fun ImageFilterSliders( + filter: VideoFilter, + showVideoOptions: Boolean, + showSaveButton: Boolean, + showSaveGalleryButton: Boolean, + onChange: (VideoFilter) -> Unit, + onClickSave: () -> Unit, + onClickSaveGallery: () -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + item { + SliderBarRow( + title = R.string.brightness, + value = filter.brightness, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(brightness = it)) }, + valueFormater = { "$it%" }, + ) + } + item { + SliderBarRow( + title = R.string.contrast, + value = filter.contrast, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(contrast = it)) }, + valueFormater = { "$it%" }, + ) + } + item { + SliderBarRow( + title = R.string.saturation, + value = filter.saturation, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(saturation = it)) }, + valueFormater = { "$it%" }, + ) + } + if (showVideoOptions) { + item { + SliderBarRow( + title = R.string.hue, + value = filter.hue, + min = 0, + max = 360, + onChange = { onChange.invoke(filter.copy(hue = it)) }, + valueFormater = { "$it\u00b0" }, + ) + } + } + item { + SliderBarRow( + title = R.string.red, + value = filter.red, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(red = it)) }, + valueFormater = { "$it%" }, + colors = + SliderColors( + activeFocused = Color.Red.copy(alpha = .75f), + activeUnfocused = Color.Red.copy(alpha = .75f), + inactiveFocused = Color.Red.copy(alpha = .33f), + inactiveUnfocused = Color.Red.copy(alpha = .33f), + ), + ) + } + item { + SliderBarRow( + title = R.string.green, + value = filter.green, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(green = it)) }, + valueFormater = { "$it%" }, + colors = + SliderColors( + activeFocused = Color.Green.copy(alpha = .75f), + activeUnfocused = Color.Green.copy(alpha = .75f), + inactiveFocused = Color.Green.copy(alpha = .33f), + inactiveUnfocused = Color.Green.copy(alpha = .33f), + ), + ) + } + item { + SliderBarRow( + title = R.string.blue, + value = filter.blue, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(blue = it)) }, + valueFormater = { "$it%" }, + colors = + SliderColors( + activeFocused = Color.Blue.copy(alpha = .75f), + activeUnfocused = Color.Blue.copy(alpha = .75f), + inactiveFocused = Color.Blue.copy(alpha = .33f), + inactiveUnfocused = Color.Blue.copy(alpha = .33f), + ), + ) + } + if (showVideoOptions) { + item { + SliderBarRow( + title = R.string.blur, + value = filter.blur, + min = 0, + max = 250, + onChange = { onChange.invoke(filter.copy(blur = it)) }, + valueFormater = { "${it}px" }, + ) + } + } + item { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier, + ) { + if (showSaveButton) { + Button( + onClick = onClickSave, + ) { + Text(text = stringResource(R.string.save)) + } + } + if (showSaveGalleryButton) { + Button( + onClick = onClickSaveGallery, + ) { + Text(text = stringResource(R.string.save_for_album)) + } + } + Button( + onClick = { onChange(VideoFilter()) }, + ) { + Text(text = stringResource(R.string.reset)) + } + } + } + } + } +} + +@Composable +fun SliderBarRow( + @StringRes title: Int, + value: Int, + min: Int, + max: Int, + onChange: (Int) -> Unit, + valueFormater: (Int) -> String, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + interval: Int = 1, + colors: SliderColors = SliderColors.default(), +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Text( + text = stringResource(title), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.width(96.dp), + ) + SliderBar( + value = value.toLong(), + min = min.toLong(), + max = max.toLong(), + interval = interval, + onChange = { + onChange.invoke(it.toInt()) + }, + colors = colors, + interactionSource = interactionSource, + enableWrapAround = true, + modifier = Modifier.weight(1f), + ) + Text( + text = valueFormater(value), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.width(48.dp), + ) + } +} + +@Composable +fun ImageFilterDialog( + filter: VideoFilter, + showVideoOptions: Boolean, + showSaveGalleryButton: Boolean, + onChange: (VideoFilter) -> Unit, + onClickSave: () -> Unit, + onClickSaveGallery: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.TOP or Gravity.END) + window.setDimAmount(0f) + } + + Box( + modifier = + modifier + .wrapContentSize() + .padding(8.dp) + .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .4f)) + .fillMaxWidth(.4f), + ) { + ImageFilterSliders( + filter = filter, + showVideoOptions = showVideoOptions, + showSaveButton = true, + showSaveGalleryButton = showSaveGalleryButton, + onChange = onChange, + onClickSave = onClickSave, + onClickSaveGallery = onClickSaveGallery, + modifier = Modifier.padding(8.dp), + ) + } + } +} + +@Preview +@Composable +private fun ImageFilterSlidersPreview() { + WholphinTheme { + ImageFilterSliders( + filter = VideoFilter(), + showVideoOptions = true, + onChange = {}, + onClickSave = {}, + onClickSaveGallery = {}, + showSaveButton = true, + showSaveGalleryButton = true, + modifier = Modifier.padding(8.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt new file mode 100644 index 00000000..a6b16ff0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.github.damontecres.wholphin.ui.components.CircularProgress +import com.github.damontecres.wholphin.ui.isNotNullOrBlank + +@Composable +fun ImageLoadingPlaceholder( + thumbnailUrl: String?, + showThumbnail: Boolean, + colorFilter: ColorFilter?, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + if (showThumbnail && thumbnailUrl.isNotNullOrBlank()) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(thumbnailUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + modifier = + Modifier + .fillMaxSize() + .align(Alignment.Center) + .alpha(.75f) + .blur(4.dp), + ) + } + CircularProgress( + Modifier + .size(80.dp) + .align(Alignment.Center), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt new file mode 100644 index 00000000..b177be74 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt @@ -0,0 +1,146 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +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.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.tv.material3.Icon +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.FontAwesome +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun ImageOverlay( + onDismiss: () -> Unit, + player: Player, + slideshowControls: SlideshowControls, + slideshowEnabled: Boolean, + position: Int, + count: Int, + image: ImageState, + onClickItem: (BaseItem) -> Unit, + onLongClickItem: (BaseItem) -> Unit, + onZoom: (Float) -> Unit, + onRotate: (Int) -> Unit, + onReset: () -> Unit, + onShowFilterDialogClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var showDialog by remember { mutableStateOf<DialogParams?>(null) } + + val moreDialogParams = + remember { + DialogParams( + fromLongClick = false, + title = "TODO", + items = + listOf( + DialogItem( + headlineContent = { + Text( + text = + if (slideshowEnabled) { + stringResource(R.string.stop_slideshow) + } else { + stringResource(R.string.play_slideshow) + }, + ) + }, + leadingContent = { + val icon = + if (slideshowEnabled) { + R.drawable.baseline_pause_24 + } else { + R.drawable.baseline_play_arrow_24 + } + Icon( + painter = painterResource(icon), + contentDescription = null, + ) + }, + onClick = { + if (slideshowEnabled) { + slideshowControls.stopSlideshow() + } else { + slideshowControls.startSlideshow() + } + }, + ), + DialogItem( + headlineContent = { + Text( + text = stringResource(R.string.filter), + ) + }, + leadingContent = { + Text( + text = stringResource(R.string.fa_sliders), + fontFamily = FontAwesome, + ) + }, + onClick = onShowFilterDialogClick, + ), + ), + ) + } + + val horizontalPadding = 16.dp + LazyColumn( + contentPadding = + PaddingValues( + start = horizontalPadding, + end = horizontalPadding, + top = 16.dp, + bottom = 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + item { + ImageDetailsHeader( + onDismiss = onDismiss, + slideshowEnabled = slideshowEnabled, + slideshowControls = slideshowControls, + player = player, + image = image, + position = position, + count = count, + moreOnClick = { + showDialog = moreDialogParams + }, + onZoom = onZoom, + onRotate = onRotate, + onReset = onReset, + onShowFilterDialogClick = onShowFilterDialogClick, + modifier = Modifier.fillMaxWidth(), + ) + } + } + showDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { showDialog = null }, + waitToLoad = params.fromLongClick, + ) + } +} 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 new file mode 100644 index 00000000..f35bc102 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -0,0 +1,500 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import android.annotation.SuppressLint +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +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.mutableFloatStateOf +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.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorMatrixColorFilter +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +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.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW +import androidx.media3.ui.compose.modifiers.resizeWithContentScale +import androidx.media3.ui.compose.state.rememberPresentationState +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.SubcomposeAsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.size.Size +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.VideoFilter +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad +import com.github.damontecres.wholphin.ui.playback.isDpad +import com.github.damontecres.wholphin.ui.playback.isEnterKey +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.MediaType +import timber.log.Timber +import kotlin.math.abs + +private const val TAG = "ImagePage" +private const val DEBUG = false + +@SuppressLint("ConfigurationScreenWidthHeight") +@OptIn(UnstableApi::class) +@Composable +fun SlideshowPage( + slideshow: Destination.Slideshow, + modifier: Modifier = Modifier, + viewModel: SlideshowViewModel = + hiltViewModel<SlideshowViewModel, SlideshowViewModel.Factory>( + creationCallback = { + it.create(slideshow) + }, + ), +) { + val context = LocalContext.current + + val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) + val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) + val position by viewModel.position.observeAsState(0) + val pager by viewModel.pager.observeAsState() + val imageState by viewModel.image.observeAsState() + + var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } + val isZoomed = zoomFactor * 100 > 102 + var rotation by rememberSaveable { mutableFloatStateOf(0f) } + var showOverlay by rememberSaveable { mutableStateOf(false) } + var showFilterDialog by rememberSaveable { mutableStateOf(false) } + var panX by rememberSaveable { mutableFloatStateOf(0f) } + var panY by rememberSaveable { mutableFloatStateOf(0f) } + + val slideshowControls = + object : SlideshowControls { + override fun startSlideshow() { + showOverlay = false + viewModel.startSlideshow() + } + + override fun stopSlideshow() { + viewModel.stopSlideshow() + } + } + + val rotateAnimation: Float by animateFloatAsState( + targetValue = rotation, + label = "image_rotation", + ) + val zoomAnimation: Float by animateFloatAsState( + targetValue = zoomFactor, + label = "image_zoom", + ) + val panXAnimation: Float by animateFloatAsState( + targetValue = panX, + label = "image_panX", + ) + val panYAnimation: Float by animateFloatAsState( + targetValue = panY, + label = "image_panY", + ) + + val slideshowState by viewModel.slideshow.collectAsState() + val slideshowActive by viewModel.slideshowActive.collectAsState(false) + + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + + val density = LocalDensity.current + val screenHeight = LocalWindowInfo.current.containerSize.height + val screenWidth = LocalWindowInfo.current.containerSize.width + + val maxPanX = screenWidth * .75f + val maxPanY = screenHeight * .75f + + fun reset(resetRotate: Boolean) { + zoomFactor = 1f + panX = 0f + panY = 0f + if (resetRotate) rotation = 0f + } + + fun pan( + xFactor: Int, + yFactor: Int, + ) { + if (xFactor != 0) { + panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) + } + if (yFactor != 0) { + panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) + } + } + + fun zoom(factor: Float) { + if (factor < 0) { + val diffFactor = factor / (zoomFactor - 1f) + // zooming out + val panXDiff = abs(panX * diffFactor) + val panYDiff = abs(panY * diffFactor) + if (DEBUG) { + Timber.d( + "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", + ) + } + if (panX > 0f) { + panX -= panXDiff + } else if (panX < 0f) { + panX += panXDiff + } + if (panY > 0f) { + panY -= panYDiff + } else if (panY < 0f) { + panY += panYDiff + } + } + zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) + if (!isZoomed) { + // Always reset if not zoomed + panX = 0f + panY = 0f + } + } + + LaunchedEffect(imageState) { + reset(true) + } + val player = viewModel.player + val presentationState = rememberPresentationState(player) + LaunchedEffect(slideshowActive) { + player.repeatMode = + if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE + } + + var longPressing by remember { mutableStateOf(false) } + + val contentModifier = + Modifier + .clickable( + interactionSource = null, + indication = null, + onClick = { + showOverlay = !showOverlay + }, + ) + + Box( + modifier = + modifier + .background(Color.Black) + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { + val isOverlayShowing = showOverlay || showFilterDialog + var result = false + if (!isOverlayShowing) { + if (longPressing && it.type == KeyEventType.KeyUp) { + // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image + longPressing = false + return@onKeyEvent true + } + longPressing = + it.nativeKeyEvent.isLongPress || + it.nativeKeyEvent.repeatCount > 0 + if (longPressing) { + when (it.key) { + Key.DirectionUp -> zoom(.05f) + Key.DirectionDown -> zoom(-.05f) + + // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan + // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } + // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } + } + return@onKeyEvent true + } + } + if (it.type != KeyEventType.KeyUp) { + result = false + } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { + // Image is zoomed in + when (it.key) { + Key.DirectionLeft -> pan(30, 0) + Key.DirectionRight -> pan(-30, 0) + Key.DirectionUp -> pan(0, 30) + Key.DirectionDown -> pan(0, -30) + } + result = true + } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { + reset(false) + result = true + } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { + when (it.key) { + Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { + if (!viewModel.previousImage()) { + Toast + .makeText( + context, + R.string.slideshow_at_beginning, + Toast.LENGTH_SHORT, + ).show() + } + } + + Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { + if (!viewModel.nextImage()) { + Toast + .makeText( + context, + R.string.no_more_images, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } else if (isOverlayShowing && it.key == Key.Back) { + showOverlay = false + viewModel.unpauseSlideshow() + result = true + } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { + showOverlay = true + viewModel.pauseSlideshow() + result = true + } + if (result) { + // Handled the key, so reset the slideshow timer + viewModel.pulseSlideshow() + } + result + }, + ) { + when (loadingState) { + ImageLoadingState.Error -> { + ErrorMessage("Error loading image", null, modifier) + } + + ImageLoadingState.Loading -> { + LoadingPage(modifier) + } + + is ImageLoadingState.Success -> { + imageState?.let { imageState -> + if (imageState.image.data.mediaType == MediaType.VIDEO) { + LaunchedEffect(imageState.id) { + val mediaItem = + MediaItem + .Builder() + .setUri(imageState.url) + .build() + player.setMediaItem(mediaItem) + player.repeatMode = + if (slideshowState.enabled) { + Player.REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ONE + } + player.prepare() + player.play() + viewModel.pulseSlideshow(Long.MAX_VALUE) + } + LifecycleStartEffect(Unit) { + onStopOrDispose { + player.stop() + } + } + val contentScale = ContentScale.Fit + val scaledModifier = + contentModifier.resizeWithContentScale( + contentScale, + presentationState.videoSizeDp, + ) + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = + scaledModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + }.rotate(rotateAnimation), + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), + ) + } + } else { + val colorFilter = + remember(imageState.id, imageFilter) { + if (imageFilter.hasImageFilter()) { + ColorMatrixColorFilter(imageFilter.colorMatrix) + } else { + null + } + } + // If the image loading is large, show the thumbnail while waiting + // TODO + val showLoadingThumbnail = true + SubcomposeAsyncImage( + modifier = + contentModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + + val xTransform = + (screenWidth - panXAnimation) / (screenWidth * 2) + val yTransform = + (screenHeight - panYAnimation) / (screenHeight * 2) + if (DEBUG) { + Timber.d( + "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", + ) + } + + transformOrigin = TransformOrigin(xTransform, yTransform) + }.rotate(rotateAnimation), + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageState.url) + .size(Size.ORIGINAL) + .crossfade(!showLoadingThumbnail) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + error = { + Text( + modifier = + Modifier + .align(Alignment.Center), + text = "Error loading image", + color = MaterialTheme.colorScheme.onBackground, + ) + }, + loading = { + ImageLoadingPlaceholder( + thumbnailUrl = imageState.thumbnailUrl, + showThumbnail = showLoadingThumbnail, + colorFilter = colorFilter, + modifier = Modifier.fillMaxSize(), + ) + }, + // Ensure that if an image takes a long time to load, it won't be skipped + onLoading = { + viewModel.pulseSlideshow(Long.MAX_VALUE) + }, + onSuccess = { + viewModel.pulseSlideshow() + }, + onError = { + Timber.e( + it.result.throwable, + "Error loading image ${imageState.id}", + ) + Toast + .makeText( + context, + "Error loading image: ${it.result.throwable.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + viewModel.pulseSlideshow() + }, + ) + } + } + } + } + AnimatedVisibility( + showOverlay, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier.align(Alignment.BottomStart), + ) { + imageState?.let { imageState -> + ImageOverlay( + modifier = + contentModifier + .fillMaxWidth() + .background(AppColors.TransparentBlack50), + onDismiss = { showOverlay = false }, + player = player, + slideshowControls = slideshowControls, + slideshowEnabled = slideshowState.enabled, + image = imageState, + position = position, + count = pager?.size ?: -1, + onClickItem = {}, + onLongClickItem = {}, + onZoom = ::zoom, + onRotate = { rotation += it }, + onReset = { reset(true) }, + onShowFilterDialogClick = { + showFilterDialog = true + showOverlay = false + viewModel.pauseSlideshow() + }, + ) + } + } + AnimatedVisibility(showFilterDialog) { + ImageFilterDialog( + filter = imageFilter, + showVideoOptions = false, + showSaveGalleryButton = true, + onChange = viewModel::updateImageFilter, + onClickSave = viewModel::saveImageFilter, + onClickSaveGallery = viewModel::saveGalleryFilter, + onDismissRequest = { + showFilterDialog = false + viewModel.unpauseSlideshow() + viewModel.pulseSlideshow() + }, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt new file mode 100644 index 00000000..992659ca --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -0,0 +1,450 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Stable +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope +import androidx.media3.common.Player +import com.github.damontecres.wholphin.data.ChosenStreams +import com.github.damontecres.wholphin.data.PlaybackEffectDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.PlaybackEffect +import com.github.damontecres.wholphin.data.model.VideoFilter +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.PlayerFactory +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.PhotoItemFields +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.onMain +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.util.ThrottledLiveData +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import dagger.assisted.Assisted +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.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import java.util.UUID +import kotlin.properties.Delegates + +@HiltViewModel(assistedFactory = SlideshowViewModel.Factory::class) +class SlideshowViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val playerFactory: PlayerFactory, + private val playbackEffectDao: PlaybackEffectDao, + private val serverRepository: ServerRepository, + private val imageUrlService: ImageUrlService, + private val userPreferencesService: UserPreferencesService, + private val screensaverService: ScreensaverService, + @Assisted val slideshowSettings: Destination.Slideshow, + ) : ViewModel(), + Player.Listener { + @AssistedFactory + interface Factory { + fun create(slideshow: Destination.Slideshow): SlideshowViewModel + } + + val player by lazy { + playerFactory.createVideoPlayer() + } + + private var saveFilters = true + + /** + * Whether slideshow mode is on or off + */ + private val _slideshow = MutableStateFlow<SlideshowState>(SlideshowState(false, false)) + val slideshow: StateFlow<SlideshowState> = _slideshow + + /** + * Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused + */ + val slideshowActive = slideshow.map { it.enabled && !it.paused } + + var slideshowDelay by Delegates.notNull<Long>() + + // private val album = MutableLiveData<BaseItem>() + private val _pager = MutableLiveData<ApiRequestPager<GetItemsRequest>>() + val pager: LiveData<List<BaseItem?>> = _pager.map { it } + val position = MutableLiveData(0) + + private val _image = MutableLiveData<ImageState>() + val image: LiveData<ImageState> = _image + + val loadingState = MutableLiveData<ImageLoadingState>(ImageLoadingState.Loading) + private val _imageFilter = MutableLiveData(VideoFilter()) + val imageFilter = ThrottledLiveData(_imageFilter, 500L) + + private var albumImageFilter = VideoFilter() + + init { + addCloseable { + screensaverService.keepScreenOn(false) + player.removeListener(this@SlideshowViewModel) + player.release() + } + player.addListener(this@SlideshowViewModel) + viewModelScope.launchIO { + val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences + slideshowDelay = + photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } + ?: AppPreference.SlideshowDuration.defaultValue +// val album = +// api.userLibraryApi +// .getItem( +// itemId = slideshowSettings.parentId, +// ).content +// .let { BaseItem(it, false) } +// this@SlideshowViewModel.album.setValueOnMain(album) + val includeItemTypes = + if (photoPrefs.slideshowPlayVideos) { + listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) + } else { + listOf(BaseItemKind.PHOTO) + } + val request = + slideshowSettings.filter.filter.applyTo( + GetItemsRequest( + parentId = slideshowSettings.parentId, + includeItemTypes = includeItemTypes, + fields = PhotoItemFields, + recursive = true, + sortBy = listOf(slideshowSettings.sortAndDirection.sort), + sortOrder = listOf(slideshowSettings.sortAndDirection.direction), + ), + ) + serverRepository.currentUser.value?.let { user -> + val filter = + playbackEffectDao + .getPlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + )?.videoFilter + if (filter != null) { + Timber.v("Got filter for album %s", slideshowSettings.parentId) + albumImageFilter = filter + } + } + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + .init(slideshowSettings.index) + this@SlideshowViewModel._pager.setValueOnMain(pager) + updatePosition(slideshowSettings.index)?.join() + if (slideshowSettings.startSlideshow) onMain { startSlideshow() } + } + } + + fun nextImage(): Boolean { + val size = pager.value?.size + val newPosition = position.value!! + 1 + return if (size != null && newPosition < size) { + updatePosition(newPosition) + true + } else { + false + } + } + + fun previousImage(): Boolean { + val newPosition = position.value!! - 1 + return if (newPosition >= 0) { + updatePosition(newPosition) + true + } else { + false + } + } + + fun updatePosition(position: Int): Job? = + _pager.value?.let { pager -> + viewModelScope.launchIO { + try { + val image = pager.getBlocking(position) + Timber.v("Got image for $position: ${image != null}") + if (image != null) { + this@SlideshowViewModel.position.setValueOnMain(position) + + val url = + if (image.data.mediaType == MediaType.VIDEO) { + // TODO this assumes direct play + api.videosApi.getVideoStreamUrl( + itemId = image.id, + ) + } else { + api.libraryApi.getDownloadUrl(image.id) + } + val chosenStreams = + if (image.data.mediaType == MediaType.VIDEO) { + image.data.mediaSources?.firstOrNull()?.let { source -> + val video = + source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO } + val audio = + source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO } + ChosenStreams( + itemPlayback = null, + plc = null, + itemId = image.id, + source = source, + videoStream = video, + audioStream = audio, + subtitleStream = null, + subtitlesDisabled = false, + ) + } + } else { + null + } + + val imageState = + ImageState( + image, + url, + imageUrlService.getItemImageUrl(image, ImageType.THUMB), + chosenStreams, + ) + // reset image filter + updateImageFilter(albumImageFilter) + if (saveFilters) { + viewModelScope.launchIO { + serverRepository.currentUser.value?.let { user -> + val vf = + playbackEffectDao + .getPlaybackEffect( + user.rowId, + image.id, + BaseItemKind.PHOTO, + ) + if (vf != null && vf.videoFilter.hasImageFilter()) { + Timber.d( + "Loaded VideoFilter for image ${image.id}", + ) + withContext(Dispatchers.Main) { + // Pause throttling so that the image loads with the filter applied immediately + imageFilter.stopThrottling(true) + updateImageFilter(vf.videoFilter) + imageFilter.startThrottling() + } + } + withContext(Dispatchers.Main) { + _image.value = imageState + loadingState.value = + ImageLoadingState.Success(imageState) + } + } + } + } else { + withContext(Dispatchers.Main) { + _image.value = imageState + loadingState.value = ImageLoadingState.Success(imageState) + } + } + } else { + loadingState.setValueOnMain(ImageLoadingState.Error) + } + } catch (ex: Exception) { + Timber.e(ex) + loadingState.setValueOnMain(ImageLoadingState.Error) + } + } + } + + private var slideshowJob: Job? = null + + fun startSlideshow() { + screensaverService.keepScreenOn(true) + _slideshow.update { + SlideshowState(enabled = true, paused = false) + } + if (_image.value + ?.image + ?.data + ?.mediaType != MediaType.VIDEO + ) { + pulseSlideshow() + } + } + + fun stopSlideshow() { + screensaverService.keepScreenOn(false) + slideshowJob?.cancel() + _slideshow.update { + SlideshowState(enabled = false, paused = false) + } + } + + fun pauseSlideshow() { + Timber.v("pauseSlideshow") + _slideshow.update { + if (it.enabled) { + slideshowJob?.cancel() + it.copy(paused = true) + } else { + it + } + } + } + + fun unpauseSlideshow() { + Timber.v("unpauseSlideshow") + _slideshow.update { + if (it.enabled) { + it.copy(paused = false) + } else { + it + } + } + } + + fun pulseSlideshow() = pulseSlideshow(slideshowDelay) + + fun pulseSlideshow(milliseconds: Long) { + Timber.v("pulseSlideshow $milliseconds") + slideshowJob?.cancel() + slideshowJob = + viewModelScope + .launchIO { + delay(milliseconds) +// Timber.v("pulseSlideshow after delay") + if (slideshowActive.first()) { + // Next image or loop to beginning + if (!nextImage()) updatePosition(0) + } + }.apply { + invokeOnCompletion { if (it !is CancellationException) pulseSlideshow() } + } + } + + fun updateImageFilter(newFilter: VideoFilter) { + viewModelScope.launchIO { + _imageFilter.setValueOnMain(newFilter) + } + } + + fun saveImageFilter() { + image.value?.let { + viewModelScope.launchIO { + val vf = _imageFilter.value + if (vf != null) { + serverRepository.currentUser.value?.let { user -> + playbackEffectDao + .insert( + PlaybackEffect( + user.rowId, + it.image.id, + BaseItemKind.PHOTO, + vf, + ), + ) + Timber.d("Saved VideoFilter for image %s", it.image.id) + withContext(Dispatchers.Main) { + showToast( + context, + "Saved", + Toast.LENGTH_SHORT, + ) + } + } + } + } + } + } + + fun saveGalleryFilter() { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + val vf = _imageFilter.value + if (vf != null) { + albumImageFilter = vf + serverRepository.currentUser.value?.let { user -> + playbackEffectDao + .insert( + PlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + vf, + ), + ) + Timber.d("Saved VideoFilter for album %s", slideshowSettings.parentId) + withContext(Dispatchers.Main) { + showToast( + context, + "Saved", + Toast.LENGTH_SHORT, + ) + } + } + } + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + pulseSlideshow(slideshowDelay) + } + } + } + +interface SlideshowControls { + fun startSlideshow() + + fun stopSlideshow() +} + +sealed class ImageLoadingState { + data object Loading : ImageLoadingState() + + data object Error : ImageLoadingState() + + data class Success( + val image: ImageState, + ) : ImageLoadingState() +} + +@Stable +data class ImageState( + val image: BaseItem, + val url: String, + val thumbnailUrl: String?, + val chosenStreams: ChosenStreams?, +) { + val id: UUID get() = image.id +} + +data class SlideshowState( + val enabled: Boolean, + val paused: Boolean, +) 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/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt index ff4e62da..ca6e4027 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt @@ -27,18 +27,18 @@ object StreamFormatting { resolutionString(height, width, interlaced) } else { when { - width <= 256 && height <= 144 -> "144" + interlaced(interlaced) - width <= 426 && height <= 240 -> "240" + interlaced(interlaced) - width <= 640 && height <= 360 -> "360" + interlaced(interlaced) - width <= 682 && height <= 384 -> "384" + interlaced(interlaced) - width <= 720 && height <= 404 -> "404" + interlaced(interlaced) - width <= 854 && height <= 480 -> "480" + interlaced(interlaced) - width <= 960 && height <= 544 -> "540" + interlaced(interlaced) - width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) - width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) - width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) - width <= 4096 && height <= 3072 -> "4K" - width <= 8192 && height <= 6144 -> "8K" + width > 5120 || height > 4320 -> "8K" + width > 2560 || height > 1440 -> "4K" + width > 1920 || height > 1080 -> "1440" + interlaced(interlaced) + width > 1280 || height > 962 -> "1080" + interlaced(interlaced) + width > 1024 || height > 576 -> "720" + interlaced(interlaced) + width > 960 || height > 544 -> "576" + interlaced(interlaced) + width > 845 || height > 480 -> "540" + interlaced(interlaced) + width > 720 || height > 404 -> "480" + interlaced(interlaced) + width > 682 || height > 384 -> "404" + interlaced(interlaced) + width > 640 || height > 360 -> "384" + interlaced(interlaced) + width > 426 || height > 240 -> "360" + interlaced(interlaced) + width > 256 || height > 144 -> "240" + interlaced(interlaced) else -> height.toString() + interlaced(interlaced) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt new file mode 100644 index 00000000..5a27f818 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt @@ -0,0 +1,82 @@ +package com.github.damontecres.wholphin.ui.util + +import android.os.Handler +import android.os.Looper +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData + +/** + * LiveData throttling value emissions so they don't happen more often than [delayMs]. + * + * From https://stackoverflow.com/a/62467521 + */ +class ThrottledLiveData<T>( + source: LiveData<T>, + delayMs: Long, +) : MediatorLiveData<T>() { + val handler = Handler(Looper.getMainLooper()) + var delayMs = delayMs + private set + + private var isValueDelayed = false + private var delayedValue: T? = null + private var delayRunnable: Runnable? = null + set(value) { + field?.let { handler.removeCallbacks(it) } + value?.let { handler.postDelayed(it, delayMs) } + field = value + } + private val objDelayRunnable = Runnable { if (consumeDelayedValue()) startDelay() } + + init { + addSource(source) { newValue -> + if (delayRunnable == null) { + value = newValue + startDelay() + } else { + isValueDelayed = true + delayedValue = newValue + } + } + } + + /** Start throttling or modify the delay. If [newDelay] is `0` (default) reuse previous delay value. */ + fun startThrottling(newDelay: Long = 0L) { + require(newDelay >= 0L) + when { + newDelay > 0 -> delayMs = newDelay + delayMs < 0 -> delayMs *= -1 + delayMs > 0 -> return + else -> throw kotlin.IllegalArgumentException("newDelay cannot be zero if old delayMs is zero") + } + } + + /** Stop throttling, if [immediate] emit any pending value now. */ + fun stopThrottling(immediate: Boolean = false) { + if (delayMs <= 0) return + delayMs *= -1 + if (immediate) consumeDelayedValue() + } + + override fun onInactive() { + super.onInactive() + consumeDelayedValue() + } + + // start counting the delay or clear it if conditions are not met + private fun startDelay() { + delayRunnable = if (delayMs > 0 && hasActiveObservers()) objDelayRunnable else null + } + + private fun consumeDelayedValue(): Boolean { + delayRunnable = null + return if (isValueDelayed) { + value = delayedValue + delayedValue = null + isValueDelayed = false + true + } else { + false + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index b03ce9f9..4b30c418 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -166,6 +166,22 @@ class ApiRequestPager<T>( } } + /** + * Dumps the cache for all the pages at or after the given position and fetches a new page + */ + suspend fun refreshPagesAfter(position: Int) { + val pageNumber = position / pageSize + cachedPages.asMap().apply { + keys.forEach { pageKey -> + if (pageKey >= pageNumber) { + if (DEBUG) Timber.v("refreshPagesAfter: dropping %s", pageKey) + remove(pageKey) + } + } + } + fetchPageBlocking(position, true) + } + companion object { private const val DEBUG = false } 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 c0e93556..fa0476cb 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, @@ -34,11 +35,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/CoroutineContextApiClient.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt new file mode 100644 index 00000000..3ea6bdf9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt @@ -0,0 +1,88 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.ApiClientFactory +import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.client.HttpMethod +import org.jellyfin.sdk.api.client.RawResponse +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.api.sockets.SocketApi +import org.jellyfin.sdk.api.sockets.SocketConnection +import org.jellyfin.sdk.api.sockets.SocketConnectionFactory +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import kotlin.coroutines.CoroutineContext + +/** + * Wraps [ApiClient.request] with the given [CoroutineContext] + */ +class CoroutineContextApiClient( + private val client: ApiClient, + private val coroutineContext: CoroutineContext = Dispatchers.IO, +) : ApiClient() { + override val baseUrl: String? + get() = client.baseUrl + override val accessToken: String? + get() = client.accessToken + override val clientInfo: ClientInfo + get() = client.clientInfo + override val deviceInfo: DeviceInfo + get() = client.deviceInfo + override val httpClientOptions: HttpClientOptions + get() = client.httpClientOptions + override val webSocket: SocketApi + get() = client.webSocket + + override fun update( + baseUrl: String?, + accessToken: String?, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ) { + client.update(baseUrl, accessToken, clientInfo, deviceInfo) + } + + override suspend fun request( + method: HttpMethod, + pathTemplate: String, + pathParameters: Map<String, Any?>, + queryParameters: Map<String, Any?>, + requestBody: Any?, + ): RawResponse = + withContext(coroutineContext) { + client.request( + method, + pathTemplate, + pathParameters, + queryParameters, + requestBody, + ) + } +} + +class CoroutineContextApiClientFactory( + private val factory: OkHttpFactory, + private val coroutineContext: CoroutineContext = Dispatchers.IO, +) : ApiClientFactory, + SocketConnectionFactory { + override fun create( + baseUrl: String?, + accessToken: String?, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + httpClientOptions: HttpClientOptions, + socketConnectionFactory: SocketConnectionFactory, + ): ApiClient = + CoroutineContextApiClient( + factory.create(baseUrl, accessToken, clientInfo, deviceInfo, httpClientOptions, socketConnectionFactory), + coroutineContext, + ) + + override fun create( + clientOptions: HttpClientOptions, + scope: CoroutineScope, + ): SocketConnection = factory.create(clientOptions, scope) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index 43d3e45a..c1630900 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util import android.content.Context import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.hilt.AppModule +import com.github.damontecres.wholphin.services.hilt.DeviceModule import com.google.auto.service.AutoService import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -49,7 +50,7 @@ class CrashReportSender : ReportSender { createJellyfin { this.context = context clientInfo = AppModule.clientInfo(context) - deviceInfo = AppModule.deviceInfo(context) + deviceInfo = DeviceModule.deviceInfo(context) apiClientFactory = okHttpFactory socketConnectionFactory = okHttpFactory minimumServerVersion = Jellyfin.minimumVersion 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<BaseItem?>, + 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/jni/event.cpp b/app/src/main/jni/event.cpp index b092b661..26c69300 100644 --- a/app/src/main/jni/event.cpp +++ b/app/src/main/jni/event.cpp @@ -107,6 +107,7 @@ void *event_thread(void *arg) case MPV_EVENT_END_FILE: mp_end_file = (mpv_event_end_file*)mp_event->data; sendEndFileEventToJava(env, mp_end_file); + break; default: ALOGV("event: %s\n", mpv_event_name(mp_event->event_id)); sendEventToJava(env, mp_event->event_id); diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 3c7f38ed..081b25e4 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -82,6 +82,7 @@ message HomePagePreferences{ int32 max_items_per_row = 1; bool enable_rewatching_next_up = 2; bool combine_continue_next = 3; + int32 max_days_next_up = 4; } enum ThemeSongVolume { @@ -127,6 +128,7 @@ message SubtitlePreferences{ bool font_italic = 10; int32 margin = 11; int32 edge_thickness = 12; + int32 image_subtitle_opacity = 13; } message LiveTvPreferences { @@ -142,6 +144,16 @@ enum BackdropStyle{ BACKDROP_NONE = 2; } +message ScreensaverPreferences{ + int64 start_delay = 1; + int64 duration = 2; + bool animate = 3; + int32 max_age_filter = 4; + bool enabled = 5; + repeated string item_types = 6; + bool show_clock = 7; +} + message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; @@ -152,12 +164,20 @@ message InterfacePreferences { SubtitlePreferences subtitles_preferences = 7; LiveTvPreferences live_tv_preferences = 8; BackdropStyle backdrop_style = 9; + SubtitlePreferences hdr_subtitles_preferences = 10; + ScreensaverPreferences screensaver_preference = 11; + bool enable_media_management = 12; } message AdvancedPreferences { int64 image_disk_cache_size_bytes = 1; } +message PhotoPreferences{ + int64 slideshow_duration = 1; + bool slideshow_play_videos = 2; +} + message AppPreferences { // The currently signed in server and user IDs, mostly for restoring a session string current_server_id = 1; @@ -173,4 +193,5 @@ message AppPreferences { bool debug_logging = 9; AdvancedPreferences advanced_preferences = 10; bool sign_in_automatically = 11; + PhotoPreferences photo_preferences = 12; } diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 4ed1e15c..8f32bc3a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -22,7 +22,7 @@ <string name="confirm">Potvrdit</string> <string name="continue_watching">Pokračovat ve sledování</string> <string name="critic_rating">Hodnocení kritiků</string> - <string name="decimal_seconds">%.1f sekund</string> + <string name="decimal_seconds">%.2f sekund</string> <string name="default_track">Výchozí</string> <string name="delete">Smazat</string> <string name="died">Zemřel</string> @@ -453,4 +453,117 @@ <string name="interlaced">Prokládané</string> <string name="color_code_programs">Color code programs</string> <string name="upgrade_mpv_toast">MPV je nyní výchozím přehrávačem mimo HDR.\nToto můžete změnit v nastavení.</string> + <string name="hdr_subtitle_style">Styl HDR titulků</string> + <string name="image_subtitle_opacity">Průhlednost obrázku titulků</string> + <string name="brightness">Jas</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Sytost</string> + <string name="hue">Odstín</string> + <string name="red">Červená</string> + <string name="green">Zelená</string> + <string name="blue">Modrá</string> + <string name="blur">Rozostření</string> + <string name="save_for_album">Uložit do alba</string> + <string name="play_slideshow">Přehrát prezentaci</string> + <string name="stop_slideshow">Zastavit prezentaci</string> + <string name="slideshow_at_beginning">Na začátek</string> + <string name="no_more_images">Žádné další fotky</string> + <string name="rotate_left">Otočit vlevo</string> + <string name="rotate_right">Otočit vpravo</string> + <string name="zoom_in">Přiblížit</string> + <string name="zoom_out">Oddálit</string> + <string name="slideshow_duration">Trvání prezentace</string> + <string name="play_videos_during_slideshow">Přehrávat videa během prezentace</string> + <string name="send_media_info_log_to_server">Odesílat protokol informací o médiích serveru</string> + <plurals name="days"> + <item quantity="one">%s den</item> + <item quantity="few">%s dny</item> + <item quantity="many">%s dní</item> + <item quantity="other">%s dní</item> + </plurals> + <string name="no_limit">Bez omezení</string> + <string name="max_days_next_up">Max. počet dnů v Dalších v pořadí</string> + <string name="quick_connect">Rychlé připojení</string> + <string name="quick_connect_summary">Autorizovat jiné zařízení k přihlášení k vašemu účtu</string> + <string name="quick_connect_code">Zadejte kód Rychlého připojení</string> + <string name="quick_connect_code_error">Kód musí mít 6 číslic</string> + <string name="quick_connect_success">Zařízení úspěšně autorizováno</string> + <string name="add_row">Přidat řádek</string> + <string name="genres_in">Žánry v %1$s</string> + <string name="recently_released_in">Nedávno vydáno v %1$s</string> + <string name="height">Výška</string> + <string name="apply_all_rows">Použít na všechny řádky</string> + <string name="customize_home">Přizpůsobit domovskou stránku</string> + <string name="home_rows">Řádky na domovské stránce</string> + <string name="load_from_server">Načíst ze serverového uživatelského profilu</string> + <string name="save_to_server">Uložit do serverového uživatelského profilu</string> + <string name="load_from_web_client">Načíst z webového klienta</string> + <string name="use_series">Použít obrázek seriálu</string> + <string name="add_row_for">Přidat řádek pro %1$s</string> + <string name="overwrite_server_settings">Přepsat nastavení na serveru?</string> + <string name="overwrite_local_settings">Přepsat místní nastavení?</string> + <string name="for_episodes">Pro epizody</string> + <string name="suggestions_for">Návrhy na %1$s</string> + <string name="increase_all_cards_size">Zvětšit velikost všech karet</string> + <string name="decrease_all_cards_size">Zmenšit velikost všech karet</string> + <string name="use_thumb_images">Použít obrázky miniatur</string> + <string name="settings_saved">Nastavení uložena</string> + <string name="display_presets">Zobrazit předvolby</string> + <string name="display_presets_description">Vestavěné předvolby pro rychlou úpravu všech řádků</string> + <string name="customize_home_summary">Vyberte řádky a obrázky na domovské stránce</string> + <string name="favorite_items">Oblíbené %s</string> + <string name="content_scale_fit">Přizpůsobit</string> + <string name="content_scale_crop">Oříznout</string> + <string name="content_scale_fill">Vyplnit</string> + <string name="content_scale_fill_width">Vyplnit šířku</string> + <string name="content_scale_fill_height">Vyplnit výšku</string> + <string name="display_preset_default">Výchozí předvolba Wholphin</string> + <string name="display_preset_compact">Wholphin kompaktní</string> + <string name="display_preset_series_thumb">Obrázky náhledů seriálů</string> + <string name="display_preset_episode_thumbnails">Obrázky náhledů epizod</string> + <string name="volume_lowest">Nejnižší</string> + <string name="volume_low">Nízká</string> + <string name="volume_medium">Střední</string> + <string name="volume_high">Vysoká</string> + <string name="volume_full">Plná</string> + <string name="purple">Fialová</string> + <string name="orange">Oranžová</string> + <string name="bold_blue">Odvážná modrá</string> + <string name="black">Černá</string> + <string name="skip_ignore">Ignorovat</string> + <string name="skip_automatically">Automaticky přeskočit</string> + <string name="skip_ask">Zeptat se</string> + <string name="ffmpeg_fallback">Použít FFmpeg pouze v případě, že neexistuje vestavěný dekodér</string> + <string name="ffmpeg_prefer">Preferovat FFmpeg nad vestavěnými dekodéry</string> + <string name="ffmpeg_never">Nikdy nepoužívat dekodéry FFmpeg</string> + <string name="next_up_playback_end">Na konci přehrávání</string> + <string name="next_up_outro">Během titulků</string> + <string name="white">Bílá</string> + <string name="light_gray">Světle šedá</string> + <string name="dark_gray">Tmavě šedá</string> + <string name="yellow">Žlutá</string> + <string name="cyan">Azurová</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Okrajová linka</string> + <string name="subtitle_edge_shadow">Stín</string> + <string name="background_style_wrap">Zabalené</string> + <string name="background_style_boxed">Krabicové</string> + <string name="prefer_mpv">Preferovat MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Použít ExoPlayer pro přehrávání HDR obsahu</string> + <string name="aspect_ratios_poster">Plakát (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Čtverec (1:1)</string> + <string name="image_type_primary">Primární</string> + <string name="image_type_thumb">Náhled</string> + <string name="backdrop_style_dynamic">Obrázek s dynamickou barvou</string> + <string name="backdrop_style_image">Pouze obrázek</string> + <string name="enter_url_api_key"><![CDATA[Zadejte adresu a klíč API]]></string> + <string name="api_key">Klíč API</string> + <string name="seerr_login">Přihlásit se k serveru Seerr</string> + <string name="seerr_jellyfin_user">Uživatel Jellyfin</string> + <string name="seerr_local_user">Lokální uživatel</string> + <string name="search_and_download_subtitles"><![CDATA[Hledat a stáhnout titulky]]></string> + <string name="no_subtitles_found">Nenalezeny žádné vzdálené titulky</string> + <string name="series_continueing">Současnost</string> </resources> 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..229bc4c8 --- /dev/null +++ b/app/src/main/res/values-da/strings.xml @@ -0,0 +1,474 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">Om</string> + <string name="active_recordings">Aktive optagelser</string> + <string name="add_favorite">Favorit</string> + <string name="add_server">Tilføj server</string> + <string name="add_user">Tilføj bruger</string> + <string name="audio">Lyd</string> + <string name="birthplace">Fødested</string> + <string name="bitrate">Bitrate</string> + <string name="born">Født</string> + <string name="cancel_recording">Annullér optagelse</string> + <string name="cancel_series_recording">Annullér serieoptagelse</string> + <string name="cancel">Annullér</string> + <string name="chapters">Kapitler</string> + <string name="choose_stream">Vælg %1$s</string> + <string name="clear_image_cache">Ryd billedcache</string> + <string name="collection">Samling</string> + <string name="collections">Samlinger</string> + <string name="community_rating">Brugerbedømmelse</string> + <string name="compose_error_message_title">Der opstod en fejl! Tryk på knappen for at sende logfiler til din server.</string> + <string name="confirm">Bekræft</string> + <string name="continue_watching">Fortsæt med at se</string> + <string name="critic_rating">Kritikerbedømmelse</string> + <string name="decimal_seconds">%.2f sekunder</string> + <string name="default_track">Standard</string> + <string name="delete">Slet</string> + <string name="died">Død</string> + <string name="directed_by">Instrueret af %1$s</string> + <string name="director">Instruktør</string> + <string name="disabled">Deaktiveret</string> + <string name="discovered_servers">Opdagede servere</string> + <string name="download_and_update"><![CDATA[Download og opdater]]></string> + <string name="downloading">Downloader…</string> + <string name="enabled">Aktiveret</string> + <string name="ends_at">Slutter %1$s</string> + <string name="enter_server_url">Indtast server-IP eller URL</string> + <string name="enter_server_address">Indtast serveradresse</string> + <string name="episodes">Episoder</string> + <string name="error_loading_collection">Fejl ved indlæsning af samling %1$s</string> + <string name="external_track">Ekstern</string> + <string name="favorites">Favoritter</string> + <string name="file_size">Størrelse</string> + <string name="forced_track">Tvunget</string> + <string name="genres">Genrer</string> + <string name="go_to_series">Gå til serien</string> + <string name="go_to">Gå til</string> + <string name="hide_controller_timeout">Skjul afspilningsknapper</string> + <string name="hide_debug_info">Skjul fejlfindingsoplysninger</string> + <string name="hide">Skjul</string> + <string name="home">Hjem</string> + <string name="immediate">Umiddelbar</string> + <string name="intro">Intro</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ</string> + <string name="library">Bibliotek</string> + <string name="license_info">Licensoplysninger</string> + <string name="live_tv">Live-tv</string> + <string name="loading">Indlæser…</string> + <string name="mark_entire_series_as_played">Markér hele serien som afspillet?</string> + <string name="mark_entire_series_as_unplayed">Markér hele serien som ikke afspillet?</string> + <string name="mark_unwatched">Markér som uset</string> + <string name="mark_watched">Markér som set</string> + <string name="max_bitrate">Maks. bitrate</string> + <string name="more_like_this">Mere som dette</string> + <string name="more">Mere</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other"/> + </plurals> + <string name="name">Navn</string> + <string name="next_up">Næste</string> + <string name="no_data">Ingen data</string> + <string name="no_results">Ingen resultater</string> + <string name="no_servers_found">Ingen servere fundet</string> + <string name="no_scheduled_recordings">Ingen planlagte optagelser</string> + <string name="no_update_available">Ingen opdatering tilgængelig</string> + <string name="none">Ingen</string> + <string name="only_forced_subtitles">Kun tvungne undertekster</string> + <string name="outro">Eftertekster</string> + <string name="path">Filsti</string> + <plurals name="people"> + <item quantity="one">Personer</item> + <item quantity="other"/> + </plurals> + <string name="play_count">Antal afspilninger</string> + <string name="play_from_here">Afspil herfra</string> + <string name="play">Afspil</string> + <string name="playback_debug_info">Vis debugoplysninger for afspilning</string> + <string name="playback_speed">Afspilningshastighed</string> + <string name="playback">Afspilning</string> + <string name="playlist">Playlist</string> + <string name="playlists">Playlists</string> + <string name="preview">Forhåndsvisning</string> + <string name="profile_specific_settings">Profilindstillinger</string> + <string name="queue">Kø</string> + <string name="recap">Opsummering</string> + <string name="recently_added_in">Nyligt tilføjet i %1$s</string> + <string name="recently_added">Nyligt tilføjet</string> + <string name="recently_recorded">Nyligt optaget</string> + <string name="recently_released">Nyligt udkommet</string> + <string name="recommended">Anbefalet</string> + <string name="record_program">Optag program</string> + <string name="record_series">Optag serie</string> + <string name="remove_favorite">Fjern fra favoritter</string> + <string name="restart">Genstart</string> + <string name="resume">Fortsæt</string> + <string name="save">Gem</string> + <string name="search_and_download"><![CDATA[Søg og download]]></string> + <string name="search">Søg</string> + <string name="searching">Søger…</string> + <string name="voice_search">Stemmesøgning</string> + <string name="voice_starting">Starter</string> + <string name="voice_search_prompt">Tal for at søge</string> + <string name="processing">Arbejder</string> + <string name="press_back_to_cancel">Tryk tilbage for at afbryde</string> + <string name="voice_error_audio">Fejl ved lydoptagelse</string> + <string name="voice_error_client">Fejl ved stemmegenkendelse</string> + <string name="voice_error_permissions">Mikrofontilladelse kræves</string> + <string name="voice_error_network">Netværksfejl</string> + <string name="voice_error_network_timeout">Netværkstimeout</string> + <string name="voice_error_no_match">Ingen tale genkendt</string> + <string name="voice_error_busy">Stemmegenkendelse optaget</string> + <string name="voice_error_server">Serverfejl</string> + <string name="voice_error_speech_timeout">Ingen tale registreret</string> + <string name="voice_error_unknown">Ukendt fejl</string> + <string name="voice_error_start_failed">Kunne ikke starte stemmegenkendelse</string> + <string name="voice_error_timeout">Tidsgrænsen for stemmegenkendelse er overskredet</string> + <string name="retry">Prøv igen</string> + <string name="select_server">Vælg server</string> + <string name="select_user">Vælg bruger</string> + <string name="show_debug_info">Vis fejlfindingsoplysninger</string> + <string name="show_next_up_when">Vis næste</string> + <string name="show">Vis</string> + <string name="shuffle">Bland</string> + <string name="skip">Spring over</string> + <string name="sort_by_date_added">Dato tilføjet</string> + <string name="sort_by_date_episode_added">Dato episode tilføjet</string> + <string name="sort_by_date_played">Dato afspillet</string> + <string name="sort_by_date_released">Udgivelsesdato</string> + <string name="sort_by_name">Navn</string> + <string name="sort_by_random">Tilfældig</string> + <string name="studios">Studier</string> + <string name="submit">Indsend</string> + <string name="subtitle_download_too_long">Downloaden tager lang tid, du skal muligvis genstarte afspilningen</string> + <string name="subtitle">Undertekst</string> + <string name="subtitles">Undertekster</string> + <string name="suggestions">Forslag</string> + <string name="switch_servers">Skift servere</string> + <string name="switch_user">Skift</string> + <string name="top_unwatched">Bedst bedømte usete</string> + <string name="trailer">Trailer</string> + <plurals name="trailers"> + <item quantity="one">Trailere</item> + <item quantity="other"/> + </plurals> + <string name="tv_dvr_schedule">DVR-plan</string> + <string name="tv_guide">TV-guide</string> + <string name="tv_season">Sæson</string> + <string name="tv_seasons">Sæsoner</string> + <plurals name="tv_shows"> + <item quantity="one">TV-serier</item> + <item quantity="other"/> + </plurals> + <string name="ui_interface">Grænseflade</string> + <string name="unknown">Ukendt</string> + <string name="updates">Opdateringer</string> + <string name="version">Version</string> + <string name="video_scale">Videoskalering</string> + <string name="video">Video</string> + <string name="videos">Videoer</string> + <string name="watch_live">Se direkte</string> + <string name="years_old">%1$d år gammel</string> + <string name="play_with_transcoding">Afspil med transkodning</string> + <string name="media_information">Medieinformation</string> + <string name="show_clock">Vis ur</string> + <string name="aired_episode_order">Udsendelsesrækkefølge</string> + <string name="create_playlist">Opret ny playliste</string> + <string name="add_to_playlist">Føj til playliste</string> + <string name="one_click_pause">Pause med ét klik</string> + <string name="one_click_pause_summary_on">Tryk på midten af D-Pad\'en for at pause/afspille</string> + <string name="italic_font">Kursiv skrift</string> + <string name="font">Skrifttype</string> + <string name="background">Baggrund</string> + <string name="success">Succes</string> + <string name="official_rating">Aldersgrænse</string> + <string name="runtime_sort">Spilletid</string> + <string name="extras">Ekstra</string> + <plurals name="other_extras"> + <item quantity="one">Andet</item> + <item quantity="other"/> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">Bag kulisserne</item> + <item quantity="other"/> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Temasange</item> + <item quantity="other"/> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">Temavideoer</item> + <item quantity="other"/> + </plurals> + <plurals name="clips"> + <item quantity="one">Klip</item> + <item quantity="other"/> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="one">Slettede scener</item> + <item quantity="other"/> + </plurals> + <plurals name="interviews"> + <item quantity="one">Interview</item> + <item quantity="other"/> + </plurals> + <plurals name="scenes"> + <item quantity="one">Scener</item> + <item quantity="other"/> + </plurals> + <plurals name="samples"> + <item quantity="one">Prøver</item> + <item quantity="other"/> + </plurals> + <plurals name="featurettes"> + <item quantity="one">Specialindslag</item> + <item quantity="other"/> + </plurals> + <plurals name="shorts"> + <item quantity="one">Kortfilm</item> + <item quantity="other"/> + </plurals> + <plurals name="downloads"> + <item quantity="one">%s download</item> + <item quantity="other">%s downloads</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d time</item> + <item quantity="other">%d timer</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s dag</item> + <item quantity="other">%s dage</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d element</item> + <item quantity="other">%d elementer</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d sekund</item> + <item quantity="other">%d sekunder</item> + </plurals> + <string name="ac3_supported">Enheden understøtter AC3/Dolby Digital</string> + <string name="advanced_settings">Avancerede indstillinger</string> + <string name="advanced_ui">Avanceret brugergrænseflade</string> + <string name="app_theme">Tema</string> + <string name="auto_check_for_updates">Søg automatisk efter opdateringer</string> + <string name="auto_play_next_delay">Forsinkelse før næste afspilning</string> + <string name="auto_play_next">Afspil næste automatisk</string> + <string name="check_for_updates">Søg efter opdateringer</string> + <string name="combine_continue_next_summary">Gælder kun for tv-serier</string> + <string name="combine_continue_next"><![CDATA[Kombiner Fortsæt med at se & Næste]]></string> + <string name="direct_play_ass">Direkte afspilning af ASS-undertekster</string> + <string name="direct_play_pgs">Direkte afspilning af PGS-undertekster</string> + <string name="downmix_stereo">Mix altid ned til stereo</string> + <string name="ffmpeg_extension_pref">Brug FFmpeg-dekodermodulet</string> + <string name="global_content_scale">Standard skalering</string> + <string name="install_update">Installer opdatering</string> + <string name="installed_version">Installeret version</string> + <string name="max_homepage_items">Maks antal elementer per række på startsiden</string> + <string name="nav_drawer_pins_summary">Vælg de standardelementer, der skal vises, andre vil blive skjult</string> + <string name="nav_drawer_pins">Tilpas elementer i navigationsmenuen</string> + <string name="nav_drawer_switch_on_focus_summary_off">Klik for at skifte side</string> + <string name="nav_drawer_switch_on_focus">Skift sider i navigationsmenuen ved fokus</string> + <string name="pass_out_protection">Inaktivitetsbeskyttelse</string> + <string name="play_theme_music">Afspil temamusik</string> + <string name="playback_overrides">Tilsidesættelser af afspilning</string> + <string name="remember_selected_tab">Husk valgte faner</string> + <string name="rewatch_next_up">Tillad gensening i \"Næste\"</string> + <string name="seek_bar_steps">Trinlængde for søgelinje</string> + <string name="send_app_logs_summary">Nyttig til fejlfinding</string> + <string name="send_app_logs">Send app-logfiler til den aktuelle server</string> + <string name="send_crash_reports_summary">Vil forsøge at sende til den sidst forbundne server</string> + <string name="send_crash_reports">Send nedbrudsrapporter</string> + <string name="settings">Indstillinger</string> + <string name="skip_back_on_resume_preference">Spring tilbage, når afspilningen genoptages</string> + <string name="skip_back_preference">Spring tilbage</string> + <string name="skip_commercials_behavior">Adfærd ved at springe reklamer over</string> + <string name="skip_forward_preference">Spring fremad</string> + <string name="skip_intro_behavior">Adfærd ved at springe intro over</string> + <string name="skip_outro_behavior">Adfærd ved at springe eftertekster over</string> + <string name="skip_previews_behavior">Adfærd ved at springe forhåndsvisninger over</string> + <string name="skip_recap_behavior">Adfærd ved at springe opsummeringer over</string> + <string name="update_available">Opdatering tilgængelig</string> + <string name="update_url_summary">URL brugt til at søge efter appopdateringer</string> + <string name="update_url">URL til opdatering</string> + <string name="font_size">Tekststørrelse</string> + <string name="font_color">Tekstfarve</string> + <string name="font_opacity">Tekstgennemsigtighed</string> + <string name="bold_font">Fed skrift</string> + <string name="edge_style">Kantstil</string> + <string name="edge_color">Kantfarve</string> + <string name="background_opacity">Baggrundsopacitet</string> + <string name="background_style">Baggrundsstil</string> + <string name="subtitle_style">Undertekststil</string> + <string name="background_color">Baggrundsfarve</string> + <string name="reset">Nulstil</string> + <string name="player_backend">Afspilning Backend</string> + <string name="mpv_hardware_decoding">MPV: Brug hardwareafkodning</string> + <string name="disable_if_crash">Deaktiver, hvis du oplever fejl</string> + <string name="mpv_options">MPV indstillinger</string> + <string name="exoplayer_options">ExoPlayer indstillinger</string> + <string name="skip_segment_unknown">Spring over segmentet</string> + <string name="skip_segment_commercial">Spring over reklamer</string> + <string name="skip_segment_preview">Spring over forhåndsvisning</string> + <string name="skip_segment_recap">Spring over opsummering</string> + <string name="skip_segment_outro">Spring over eftertekster</string> + <string name="skip_segment_intro">Spring over intro</string> + <string name="played">Afspillet</string> + <string name="filter">Filter</string> + <string name="year">År</string> + <string name="decade">Årti</string> + <string name="remove">Fjern</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="mpv_use_gpu_next">MPV: Brug gpu-next</string> + <string name="mpv_conf">Rediger mpv.conf</string> + <string name="discard_change">Kassér ændringer?</string> + <string name="subtitle_margin">Margin</string> + <string name="verbose_logging">Detaljeret logning</string> + <string name="enter_pin">Indtast PIN</string> + <string name="sign_in_auto">Log ind automatisk</string> + <string name="use_server_credentials">Log ind via server</string> + <string name="press_enter_to_confirm">Tryk på midten for at bekræfte</string> + <string name="require_pin_code">Kræv PIN-kode til profil</string> + <string name="confirm_pin">Bekræft PIN</string> + <string name="incorrect">Fejl</string> + <string name="pin_too_short">PIN-koden skal være 4 cifre eller længere</string> + <string name="will_remove_pin">Vil fjerne PIN</string> + <string name="image_cache_size">Størrelse på billeddiskcache (MB)</string> + <string name="view_options">Vis muligheder</string> + <string name="columns">Kolonner</string> + <string name="spacing">Mellemrum</string> + <string name="aspect_ratio">Aspektforhold</string> + <string name="show_details">Vis detaljer</string> + <string name="image_type">Billedtype</string> + <string name="cast_and_crew"><![CDATA[Skuespillere og filmhold]]></string> + <string name="guest_stars">Gæsteroller</string> + <string name="edge_size">Kantstørrelse</string> + <string name="refresh_rate_switching">Skift af billedopdateringsfrekvens</string> + <string name="automatic">Automatisk</string> + <string name="username_or_password">Brug brugernavn/adgangskode</string> + <string name="login">Log ind</string> + <string name="general">Generelt</string> + <string name="container">Container</string> + <string name="title">Titel</string> + <string name="codec">Codec</string> + <string name="profile">Profil</string> + <string name="level">Niveau</string> + <string name="resolution">Opløsning</string> + <string name="anamorphic">Anamorfisk</string> + <string name="interlaced">Interlaced</string> + <string name="framerate">Billedfrekvens</string> + <string name="bit_depth">Bitdybde</string> + <string name="video_range">Videoområde</string> + <string name="video_range_type">Type af videoområde</string> + <string name="color_space">Farverum</string> + <string name="color_transfer">Farveoverførsel</string> + <string name="color_primaries">Primære farver</string> + <string name="pixel_format">Pixel-format</string> + <string name="ref_frames">Referencerammer</string> + <string name="nal">NAL</string> + <string name="language">Sprog</string> + <string name="layout">Layout</string> + <string name="channels">Kanaler</string> + <string name="sample_rate">Samplingsfrekvens</string> + <string name="avc">AVC</string> + <string name="yes">Ja</string> + <string name="no">Nej</string> + <string name="sdr">SDR</string> + <string name="hdr">HDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="bit_unit">bit</string> + <string name="sample_rate_unit">Hz</string> + <string name="show_titles">Vis titler</string> + <string name="live_tv_repeat">Gentag</string> + <string name="favorite_channels_at_beginning">Vis favoritkanaler først</string> + <string name="sort_channels_recently_watched">Sorter kanaler efter nyligt set</string> + <string name="color_code_programs">Farvekod programmer</string> + <string name="subtitle_delay">Undertekstforsinkelse</string> + <string name="backdrop_display">Stil for baggrundsbillede</string> + <string name="resolution_switching">Skift af opløsning</string> + <string name="local">Lokal</string> + <string name="play_trailer">Afspil trailer</string> + <string name="no_trailers">Ingen trailere</string> + <string name="clear_track_choices">Ryd valg af spor</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="truehd">TrueHD</string> + <string name="dolby_digital">DD</string> + <string name="dolby_digital_plus">DD+</string> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Direkte afspilning af Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Ignorerer kontrol af enhedskompatibilitet</string> + <string name="discover">Opdag</string> + <string name="request">Anmod</string> + <string name="pending">Afventer</string> + <string name="seerr_integration">Seerr integration</string> + <string name="remove_seerr_server">Fjern Seerr server</string> + <string name="seerr_server_added">Seerr server tilføjet</string> + <string name="quick_connect">Quick Connect</string> + <string name="quick_connect_summary">Godkend en anden enhed til at logge ind på din konto</string> + <string name="quick_connect_code">Indtast Quick Connect-kode</string> + <string name="quick_connect_code_error">Koden skal være på 6 cifre</string> + <string name="quick_connect_success">Enheden er godkendt</string> + <string name="password">Adgangskode</string> + <string name="username">Brugernavn</string> + <string name="url">URL</string> + <string name="trending">Populært lige nu</string> + <string name="upcoming_movies">Kommende film</string> + <string name="upcoming_tv">Kommende tv-serier</string> + <string name="request_4k">Anmod i 4K</string> + <string name="software_decoding_av1">AV1 software afkodning</string> + <string name="upgrade_mpv_toast">MPV er nu standardafspilleren undtagen for HDR.\nDu kan ændre dette i indstillingerne.</string> + <string name="hdr_subtitle_style">HDR-undertekststil</string> + <string name="image_subtitle_opacity">Billedunderteksters gennemsigtighed</string> + <string name="brightness">Lysstyrke</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Mætning</string> + <string name="hue">Farvetone</string> + <string name="red">Rød</string> + <string name="green">Grøn</string> + <string name="blue">Blå</string> + <string name="blur">Sløring</string> + <string name="save_for_album">Gem i album</string> + <string name="play_slideshow">Afspil diasshow</string> + <string name="stop_slideshow">Stop diasshow</string> + <string name="slideshow_at_beginning">Fra begyndelsen</string> + <string name="no_more_images">Ikke flere billeder</string> + <string name="rotate_left">Roter til venstre</string> + <string name="rotate_right">Roter til højre</string> + <string name="zoom_in">Zoom ind</string> + <string name="zoom_out">Zoom ud</string> + <string name="slideshow_duration">Diasshowvarighed</string> + <string name="play_videos_during_slideshow">Afspil videoer under diasshow</string> + <string name="send_media_info_log_to_server">Send medieinformationslog til serveren</string> + <string name="no_limit">Ingen grænse</string> + <string name="max_days_next_up">Maks. antal dage i Næste</string> + <string name="add_row">Tilføj række</string> + <string name="genres_in">Genrer i %1$s</string> + <string name="recently_released_in">Nyligt udkommet i %1$s</string> + <string name="height">Højde</string> + <string name="apply_all_rows">Anvend på alle rækker</string> + <string name="customize_home">Tilpas startsiden</string> + <string name="home_rows">Rækker på startsiden</string> + <string name="load_from_server">Indlæs fra brugerprofilen på serveren</string> + <string name="save_to_server">Gem i brugerprofilen på serveren</string> + <string name="load_from_web_client">Indlæs fra webklienten</string> + <string name="use_series">Brug seriens billede</string> + <string name="add_row_for">Tilføj række for %1$s</string> + <string name="overwrite_server_settings">Overskriv indstillinger på serveren?</string> + <string name="overwrite_local_settings">Overskriv lokale indstillinger?</string> + <string name="for_episodes">For episoder</string> + <string name="suggestions_for">Forslag til %1$s</string> + <string name="increase_all_cards_size">Øg størrelsen på alle kort</string> + <string name="decrease_all_cards_size">Reducer størrelsen på alle kort</string> + <string name="use_thumb_images">Brug thumb-billeder</string> + <string name="settings_saved">Indstillinger gemt</string> + <string name="display_presets">Skærmforudindstillinger</string> + <string name="display_presets_description">Indbyggede forudindstillinger til hurtigt at style alle rækker</string> + <string name="customize_home_summary">Vælg rækker og billeder på startsiden</string> + <string name="commercial">Reklame</string> +</resources> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 03038fc8..013f5ccd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,7 +19,7 @@ <string name="confirm">Bestätigen</string> <string name="continue_watching">Weiterschauen</string> <string name="critic_rating">Kritiken</string> - <string name="decimal_seconds">%.1f Sekunden</string> + <string name="decimal_seconds">%.2f Sekunden</string> <string name="default_track">Standard</string> <string name="delete">Löschen</string> <string name="died">Gestorben</string> @@ -321,7 +321,7 @@ <string name="no">Nein</string> <string name="show_titles">Titel anzeigen</string> <string name="live_tv_repeat">Wiederholen</string> - <string name="general">Generell</string> + <string name="general">Allgemein</string> <string name="no_trailers">Keine Trailer</string> <string name="play_trailer">Trailer abspielen</string> <string name="local">Lokal</string> @@ -371,7 +371,7 @@ <string name="trending">Im Trend</string> <string name="voice_error_start_failed">Starten der Spracherkennung fehlgeschlagen</string> <string name="press_back_to_cancel">Zurück wählen um abzubrechen</string> - <string name="upgrade_mpv_toast">MPV ist jetzt der Standard-Player, außer für HDR.\nDas kannst du in den Einstellungen ändern.</string> + <string name="upgrade_mpv_toast">MPV ist jetzt der Standard-Player, außer für HDR.\nDu kannst das in den Einstellungen ändern.</string> <string name="voice_search">Sprachsuche</string> <string name="voice_search_prompt">Sprechen, um zu suchen</string> <string name="force_dovi_profile_7_summary">Ignoriert geräte kompatibilität</string> @@ -384,4 +384,52 @@ <string name="backdrop_display">Hintergrundstil</string> <string name="refresh_rate_switching">Bildwiederholraten-Anpassung</string> <string name="voice_error_speech_timeout">Keine Spracheingabe erkannt</string> + <string name="voice_error_client">Spracherkennungsfehler</string> + <string name="voice_error_audio">Aufnahme-Fehler</string> + <string name="voice_error_no_match">Keine Spracherkennung</string> + <string name="voice_error_busy">Spracherkennung beschäftigt</string> + <string name="voice_error_timeout">Spracherkennung ist abgelaufen</string> + <string name="video_range">Videobereich</string> + <string name="video_range_type">Video-Bereichstyp</string> + <string name="color_code_programs">Farbkorrektur-Software</string> + <string name="clear_track_choices">Spur-Auswahl löschen</string> + <string name="remove_seerr_server">Seerr-Server entfernen</string> + <string name="software_decoding_av1">AV1 Software-Dekodierung</string> + <string name="anamorphic">anamorph</string> + <string name="color_space">Farbraum</string> + <string name="color_transfer">Farbübertragung</string> + <string name="color_primaries">Primärfarben</string> + <string name="pixel_format">Pixelformat</string> + <string name="ref_frames">Referenzbilder</string> + <string name="edge_size">Randgröße</string> + <string name="resolution_switching">Auflösungswechsel</string> + <string name="guest_stars">Gaststars</string> + <string name="slideshow_at_beginning">Ab Anfang</string> + <string name="no_more_images">keine Fotos</string> + <string name="rotate_left">Links Rotieren</string> + <string name="rotate_right">Rechts Rotieren</string> + <string name="red">Rot</string> + <string name="green">Grün</string> + <string name="blue">Blau</string> + <string name="blur">Verschwommen</string> + <string name="save_for_album">Speichern für Album</string> + <string name="play_slideshow">Diashow abspielen</string> + <string name="stop_slideshow">Diashow anhalten</string> + <string name="brightness">Helligkeit</string> + <string name="contrast">Kontrast</string> + <string name="zoom_out">Verkleinern</string> + <string name="zoom_in">Vergrößern</string> + <string name="quick_connect_success">Gerät erfolgreich authorisiert</string> + <string name="saturation">Sättigung</string> + <string name="hue">Farbton</string> + <plurals name="days"> + <item quantity="one">%s Tag</item> + <item quantity="other">%s Tage</item> + </plurals> + <string name="quick_connect_code_error">Der Code muss aus 6 Ziffern bestehen</string> + <string name="hdr_subtitle_style">HDR-Untertitelstil</string> + <string name="slideshow_duration">Dauer der Diashow</string> + <string name="play_videos_during_slideshow">Videos während der Diashow abspielen</string> + <string name="max_days_next_up">Maximale Tage in \"Als nächstes\"</string> + <string name="quick_connect">Quick Connect</string> </resources> diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 4d7b297d..6159ff33 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -171,35 +171,35 @@ <string name="extras">Επιπρόσθετα</string> <plurals name="other_extras"> <item quantity="one">Άλλο</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Παρασκήνια</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="clips"> <item quantity="one">Αποσπάσματα</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Διαγραμμένες σκηνές</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="interviews"> <item quantity="one">Συνεντεύξεις</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="scenes"> <item quantity="one">Σκηνές</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="samples"> <item quantity="one">Δείγματα</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="featurettes"> <item quantity="one">Χαρακτηριστικά</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <string name="auto_check_for_updates">Αυτόματος έλεγχος για ενημερώσεις</string> <string name="auto_play_next_delay">Καθυστέρηση πριν την έναρξη του επόμενου</string> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 35d7e885..b4ef66da 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -12,10 +12,10 @@ <string name="choose_stream">Elegir %1$s</string> <string name="collection">Colección</string> <string name="collections">Colecciones</string> - <string name="commercial">Comercial</string> + <string name="commercial">Anuncio</string> <string name="confirm">Confirmar</string> <string name="continue_watching">Continuar viendo</string> - <string name="decimal_seconds">%.1f segundos</string> + <string name="decimal_seconds">%.2f segundos</string> <string name="delete">Eliminar</string> <string name="died">Fallecido</string> <string name="enabled">Activado</string> @@ -24,11 +24,11 @@ <string name="favorites">Favoritos</string> <string name="file_size">Tamaño</string> <string name="genres">Géneros</string> - <string name="home">Página de inicio</string> + <string name="home">Inicio</string> <string name="immediate">Inmediato</string> <string name="intro">Intro</string> <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> - <string name="library">Catálogo</string> + <string name="library">Biblioteca</string> <string name="license_info">Información de licencia</string> <string name="max_bitrate">Tasa de bits máxima</string> <string name="more">Más</string> @@ -46,14 +46,14 @@ <string name="recently_added_in">Agregado recientemente en %1$s</string> <string name="recently_added">Agregado recientemente</string> <string name="recommended">Recomendado</string> - <string name="restart">Reiniciarlo</string> + <string name="restart">Reiniciar</string> <string name="resume">Reanudar</string> <string name="save">Guardar</string> <string name="search_and_download"><![CDATA[Buscar y descargar]]></string> <string name="search">Buscar</string> <string name="searching">Buscando…</string> <string name="select_server">Seleccionar servidor</string> - <string name="select_user">Seleccionar usuario</string> + <string name="select_user">Seleccionar Usuario</string> <string name="sort_by_name">Nombre</string> <string name="sort_by_random">Aleatorio</string> <string name="studios">Estudios</string> @@ -65,15 +65,15 @@ <string name="switch_user">Cambiar</string> <string name="trailer">Tráiler</string> <plurals name="trailers"> - <item quantity="one">Tráilers</item> - <item quantity="many"/> + <item quantity="one">Tráiler</item> + <item quantity="many">Tráilers</item> <item quantity="other"/> </plurals> <string name="tv_shows">Series</string> <string name="ui_interface">Interfaz</string> <string name="version">Versión</string> - <string name="video">Video</string> - <string name="videos">Videos</string> + <string name="video">Vídeo</string> + <string name="videos">Vídeos</string> <string name="years_old">%1$d años</string> <string name="play_with_transcoding">Reproducir con transcodificación</string> <string name="show_clock">Mostrar reloj</string> @@ -85,8 +85,8 @@ <string name="runtime_sort">Duración</string> <string name="extras">Extras</string> <plurals name="other_extras"> - <item quantity="one">Otros</item> - <item quantity="many"/> + <item quantity="one">Otro</item> + <item quantity="many">Otros</item> <item quantity="other"/> </plurals> <string name="advanced_settings">Configuración avanzada</string> @@ -97,19 +97,19 @@ <string name="settings">Configuración</string> <string name="critic_rating">Calificación de la crítica</string> <string name="recap">Resumen</string> - <string name="record_program">Grabar programa</string> + <string name="record_program">Grabar Programa</string> <string name="record_series">Grabar serie</string> <string name="remove_favorite">Quitar de favoritos</string> <string name="show_debug_info">Mostrar información de depuración</string> <string name="show">Mostrar</string> <string name="shuffle">Aleatorio</string> <string name="skip">Saltar</string> - <string name="sort_by_date_episode_added">Ordenar por fecha de añadido del episodio</string> + <string name="sort_by_date_episode_added">Ordenar por fecha de inclusión del episodio</string> <string name="about">Acerca de</string> <string name="active_recordings">Grabaciones activas</string> <string name="cancel_recording">Cancelar grabación</string> <string name="cancel_series_recording">Cancelar grabación de la serie</string> - <string name="clear_image_cache">Borrar la caché de imágenes</string> + <string name="clear_image_cache">Borrar caché de imágenes</string> <string name="community_rating">Valoración de la comunidad</string> <string name="compose_error_message_title">¡Se ha producido un error! Pulsa el botón para enviar los registros a tu servidor.</string> <string name="default_track">Predeterminado</string> @@ -119,41 +119,41 @@ <string name="discovered_servers">Servidores detectados</string> <string name="download_and_update"><![CDATA[Descargar y actualizar]]></string> <string name="downloading">Descargando…</string> - <string name="enter_server_url">Introduce la IP o la URL del servidor</string> - <string name="error_loading_collection">Error al cargar la colección %1$s</string> - <string name="forced_track">Pista forzada</string> + <string name="enter_server_url">Introduce la IP o URL del servidor</string> + <string name="error_loading_collection">Error cargando la colección %1$s</string> + <string name="forced_track">Forzado</string> <string name="go_to_series">Ir a la serie</string> <string name="go_to">Ir a</string> - <string name="hide_controller_timeout">Ocultar los controles de reproducción</string> + <string name="hide_controller_timeout">Ocultar controles de reproducción</string> <string name="hide_debug_info">Ocultar información de depuración</string> <string name="hide">Ocultar</string> - <string name="live_tv">Televisión en directo</string> + <string name="live_tv">TV en vivo</string> <string name="loading">Cargando…</string> - <string name="mark_entire_series_as_played">¿Marcar toda la serie como vista?</string> - <string name="mark_entire_series_as_unplayed">¿Marcar toda la serie como no reproducida?</string> + <string name="mark_entire_series_as_played">¿Marcar la serie completa como vista?</string> + <string name="mark_entire_series_as_unplayed">¿Marcar la serie completa como no vista?</string> <string name="mark_unwatched">Marcar como no visto</string> <string name="mark_watched">Marcar como visto</string> <string name="more_like_this">Más como esto</string> <string name="movies">Películas</string> - <string name="next_up">A continuación</string> + <string name="next_up">Siguiente</string> <string name="no_data">Sin datos</string> - <string name="no_scheduled_recordings">No hay grabaciones programadas</string> - <string name="no_update_available">No hay ninguna actualización disponible</string> + <string name="no_scheduled_recordings">Sin grabaciones programadas</string> + <string name="no_update_available">No hay actualizaciones disponibles</string> <string name="path">Ruta</string> - <string name="play_count">Recuento de reproducciones</string> + <string name="play_count">Reproducciones</string> <string name="play_from_here">Reproducir desde aquí</string> <string name="playback_debug_info">Mostrar información de depuración de reproducción</string> <string name="playback_speed">Velocidad de reproducción</string> <string name="profile_specific_settings">Configuración del perfil de usuario</string> <string name="recently_recorded">Grabado recientemente</string> <string name="recently_released">Lanzado recientemente</string> - <string name="show_next_up_when">Mostrar \"A continuación\"</string> - <string name="sort_by_date_added">Fecha de añadido</string> - <string name="sort_by_date_played">Ordenar por fecha de reproducción</string> - <string name="sort_by_date_released">Ordenar por fecha de lanzamiento</string> - <string name="subtitle_download_too_long">La descarga está tardando demasiado; puede que necesites reiniciar la reproducción</string> - <string name="top_unwatched">Mejor valoradas sin ver</string> - <string name="tv_dvr_schedule">Programación del DVR</string> + <string name="show_next_up_when">Mostrar “Siguiente”</string> + <string name="sort_by_date_added">Fecha de inclusión</string> + <string name="sort_by_date_played">Fecha de reproducción</string> + <string name="sort_by_date_released">Fecha de lanzamiento</string> + <string name="subtitle_download_too_long">La descarga está tardando demasiado, es posible que debas reiniciar la reproducción</string> + <string name="top_unwatched">Mejor calificados no vistos</string> + <string name="tv_dvr_schedule">Programación DVR</string> <string name="tv_guide">Guía</string> <string name="tv_season">Temporada</string> <string name="tv_seasons">Temporadas</string> @@ -162,15 +162,15 @@ <string name="video_scale">Escala de vídeo</string> <string name="watch_live">Ver en directo</string> <string name="aired_episode_order">Orden de emisión</string> - <string name="italic_font">Poner la fuente en cursiva</string> + <string name="italic_font">Cursiva</string> <plurals name="behind_the_scenes"> - <item quantity="one">Detrás de las cámaras</item> + <item quantity="one">Detrás de cámaras</item> <item quantity="many"/> <item quantity="other"/> </plurals> <plurals name="theme_songs"> - <item quantity="one">Canciones principales</item> - <item quantity="many"/> + <item quantity="one">Canción principal</item> + <item quantity="many">Canciones principales</item> <item quantity="other"/> </plurals> <plurals name="theme_videos"> @@ -181,26 +181,26 @@ <plurals name="clips"> <item quantity="one">Clip</item> <item quantity="many">Clips</item> - <item quantity="other"></item> + <item quantity="other">Clips</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Escenas eliminadas</item> - <item quantity="many"/> + <item quantity="one">Escena eliminada</item> + <item quantity="many">Escenas eliminadas</item> <item quantity="other"/> </plurals> <plurals name="interviews"> - <item quantity="one">Entrevistas</item> - <item quantity="many"/> + <item quantity="one">Entrevista</item> + <item quantity="many">Entrevistas</item> <item quantity="other"/> </plurals> <plurals name="scenes"> - <item quantity="one">Escenas</item> - <item quantity="many"/> + <item quantity="one">Escena</item> + <item quantity="many">Escenas</item> <item quantity="other"/> </plurals> <plurals name="samples"> - <item quantity="one">Fragmentos</item> - <item quantity="many"/> + <item quantity="one">Muestra</item> + <item quantity="many">Muestras</item> <item quantity="other"/> </plurals> <plurals name="featurettes"> @@ -209,7 +209,7 @@ <item quantity="other"/> </plurals> <plurals name="shorts"> - <item quantity="one">Cortos</item> + <item quantity="one">CortoCortos</item> <item quantity="many"/> <item quantity="other"/> </plurals> @@ -233,32 +233,32 @@ <item quantity="many">%d segundos</item> <item quantity="other"/> </plurals> - <string name="ac3_supported">El dispositivo es compatible con AC3/Dolby Digital</string> - <string name="auto_check_for_updates">Comprobar actualizaciones automáticamente</string> - <string name="auto_play_next_delay">Retraso antes de reproducir lo siguiente</string> - <string name="auto_play_next">Reproducir automáticamente lo siguiente</string> - <string name="check_for_updates">Comprobar si hay actualizaciones</string> - <string name="combine_continue_next_summary">Se aplica solo a series de televisión</string> - <string name="combine_continue_next"><![CDATA[Combinar “Seguir viendo” y “A continuación”]]></string> + <string name="ac3_supported">El dispositivo soporta AC3/Dolby Digital</string> + <string name="auto_check_for_updates">Buscar actualizaciones automáticamente</string> + <string name="auto_play_next_delay">Retraso antes de reproducir el siguiente</string> + <string name="auto_play_next">Reproducción automática del siguiente</string> + <string name="check_for_updates">Buscar actualizaciones</string> + <string name="combine_continue_next_summary">Aplica solo a series</string> + <string name="combine_continue_next"><![CDATA[Combinar “Continuar viendo” y “Siguiente”]]></string> <string name="direct_play_ass">Reproducción directa de subtítulos ASS</string> <string name="direct_play_pgs">Reproducción directa de subtítulos PGS</string> <string name="downmix_stereo">Convertir siempre a estéreo</string> - <string name="ffmpeg_extension_pref">Usar módulo de decodificación FFmpeg</string> - <string name="global_content_scale">Escala predeterminada del contenido</string> + <string name="ffmpeg_extension_pref">Usar módulo decodificador FFmpeg</string> + <string name="global_content_scale">Escala de contenido predeterminada</string> <string name="install_update">Instalar actualización</string> - <string name="max_homepage_items">Máximo de elementos por fila en inicio</string> + <string name="max_homepage_items">Elementos máximos en filas de inicio</string> <string name="nav_drawer_pins_summary">Elige los elementos predeterminados que se mostrarán; los demás permanecerán ocultos</string> - <string name="nav_drawer_pins">Personalizar los elementos del panel de navegación</string> + <string name="nav_drawer_pins">Personalizar elementos del panel de navegación</string> <string name="nav_drawer_switch_on_focus_summary_off">Clic para cambiar de página</string> - <string name="nav_drawer_switch_on_focus">Cambiar las páginas del menú lateral al recibir el foco</string> + <string name="nav_drawer_switch_on_focus">Cambiar páginas del menú al enfocar</string> <string name="pass_out_protection">Protección contra inactividad</string> <string name="playback_overrides">Anulaciones de reproducción</string> - <string name="remember_selected_tab">Recordar las pestañas seleccionadas</string> - <string name="rewatch_next_up">Permitir volver a ver en “A continuación”</string> + <string name="remember_selected_tab">Recordar pestañas seleccionadas</string> + <string name="rewatch_next_up">Permitir volver a ver en “Siguiente”</string> <string name="seek_bar_steps">Pasos de la barra de búsqueda</string> <string name="send_app_logs_summary">Útil para depuración</string> - <string name="send_app_logs">Enviar los registros de la app al servidor actual</string> - <string name="send_crash_reports_summary">Intentará enviarlos al último servidor conectado</string> + <string name="send_app_logs">Enviar registros de la app al servidor actual</string> + <string name="send_crash_reports_summary">Intentará enviarse al último servidor conectado</string> <string name="send_crash_reports">Enviar informes de fallos</string> <string name="skip_back_on_resume_preference">Retroceder al reanudar la reproducción</string> <string name="skip_back_preference">Retroceder</string> @@ -266,22 +266,22 @@ <string name="skip_forward_preference">Avanzar</string> <string name="skip_intro_behavior">Comportamiento al saltar la intro</string> <string name="skip_outro_behavior">Comportamiento al saltar el outro</string> - <string name="skip_previews_behavior">Comportamiento al saltar los avances</string> - <string name="skip_recap_behavior">Comportamiento al saltar el resumen</string> + <string name="skip_previews_behavior">Comportamiento al saltar avances</string> + <string name="skip_recap_behavior">Comportamiento al saltar resumen</string> <string name="update_available">Actualización disponible</string> - <string name="update_url_summary">URL para comprobar actualizaciones de la aplicación</string> + <string name="update_url_summary">URL usada para comprobar actualizaciones</string> <string name="update_url">URL de actualización</string> <string name="font_size">Tamaño de fuente</string> - <string name="font_color">Color de la fuente</string> - <string name="font_opacity">Opacidad de la fuente</string> + <string name="font_color">Color de fuente</string> + <string name="font_opacity">Opacidad de fuente</string> <string name="edge_style">Estilo del borde</string> <string name="edge_color">Color del borde</string> <string name="background_opacity">Opacidad del fondo</string> <string name="background_style">Estilo del fondo</string> - <string name="subtitle_style">Estilo de los subtítulos</string> + <string name="subtitle_style">Estilo de subtítulos</string> <string name="background_color">Color del fondo</string> <string name="reset">Restablecer</string> - <string name="bold_font">Fuente en negrita</string> + <string name="bold_font">Negrita</string> <string name="player_backend">Motor de reproducción</string> <string name="mpv_hardware_decoding">MPV: Usar decodificación por hardware</string> <string name="disable_if_crash">Desactívalo si experimentas fallos</string> @@ -303,7 +303,7 @@ <string name="font">Fuente</string> <string name="background">Fondo</string> <string name="official_rating">Clasificación parental</string> - <string name="ends_at">Termina en %1$s</string> + <string name="ends_at">Termina a las %1$s</string> <string name="enter_server_address">Introduzca la dirección del servidor</string> <string name="no_servers_found">No se encontraron servidores</string> <string name="media_information">Información multimedia</string> @@ -385,13 +385,13 @@ <string name="voice_error_client">Error de reconocimiento de voz</string> <string name="voice_error_permissions">Se requiere permiso para el micrófono</string> <string name="voice_error_network">Error de red</string> - <string name="voice_error_network_timeout">Tiempo de espera agotado de red</string> + <string name="voice_error_network_timeout">Tiempo de espera de red agotado</string> <string name="voice_error_no_match">No se reconoce el habla</string> <string name="voice_error_busy">Reconocimiento de voz ocupado</string> <string name="voice_error_server">Error de servidor</string> <string name="voice_error_speech_timeout">No se detecta voz</string> <string name="voice_error_unknown">Error desconocido</string> - <string name="voice_error_start_failed">No se pudo iniciar el reconocimiento de voz</string> + <string name="voice_error_start_failed">Error al iniciar el reconocimiento de voz</string> <string name="voice_error_timeout">Se agotó el tiempo de espera para el reconocimiento de voz</string> <string name="retry">Reintentar</string> <string name="resolution_switching">Cambio de resolución</string> @@ -421,4 +421,117 @@ <string name="upcoming_tv">Próximos programas de televisión</string> <string name="request_4k">Solicitar en 4K</string> <string name="software_decoding_av1">Decodificación por software AV1</string> + <string name="upgrade_mpv_toast">MPV es ahora el reproductor por defecto para HDR.\nPuedes cambiar esto en los ajustes.</string> + <plurals name="days"> + <item quantity="one">%s día</item> + <item quantity="many">%s días</item> + <item quantity="other">%s días</item> + </plurals> + <string name="quick_connect">Conexión Rápida</string> + <string name="quick_connect_summary">Autoriza a otro dispositivo para acceder a tu cuenta</string> + <string name="quick_connect_code">Introduce el código de Conexión Rápida</string> + <string name="quick_connect_code_error">El código debe tener 6 dígitos</string> + <string name="quick_connect_success">Dispositivo autorizado con éxito</string> + <string name="hdr_subtitle_style">Estilo de subtítulos HDR</string> + <string name="image_subtitle_opacity">Opacidad de los subtítulos</string> + <string name="brightness">Brillo</string> + <string name="contrast">Contraste</string> + <string name="saturation">Saturación</string> + <string name="hue">Tono de color</string> + <string name="red">Rojo</string> + <string name="green">Verde</string> + <string name="blue">Azul</string> + <string name="blur">Desenfoque</string> + <string name="save_for_album">Guardar en el álbum</string> + <string name="play_slideshow">Reproducir presentación</string> + <string name="stop_slideshow">Detener presentación</string> + <string name="slideshow_at_beginning">Presentación inicial</string> + <string name="no_more_images">No hay más imágenes</string> + <string name="rotate_left">Girar a la izquierda</string> + <string name="rotate_right">Girar a la derecha</string> + <string name="zoom_in">Acercar</string> + <string name="zoom_out">Alejar</string> + <string name="slideshow_duration">Duración de la presentación</string> + <string name="play_videos_during_slideshow">Reproducir vídeos durante la presentación</string> + <string name="send_media_info_log_to_server">Enviar registro de medios al servidor</string> + <string name="no_limit">Sin límite</string> + <string name="max_days_next_up">Límite de días en \"Siguiente\"</string> + <string name="add_row">Añadir fila</string> + <string name="genres_in">Géneros de %1$s</string> + <string name="height">Altura</string> + <string name="apply_all_rows">Aplicar a todas las filas</string> + <string name="customize_home">Personalizar página de inicio</string> + <string name="home_rows">Filas de inicio</string> + <string name="load_from_server">Cargar perfil de usuario desde el servidor</string> + <string name="save_to_server">Guardar perfil de usuario en el servidor</string> + <string name="load_from_web_client">Cargar del cliente web</string> + <string name="use_series">Usar imágenes de la serie</string> + <string name="add_row_for">Agregar fila para %1$s</string> + <string name="overwrite_server_settings">¿Sobrescribir ajustes en el servidor?</string> + <string name="overwrite_local_settings">¿Sobrescribir ajustes locales?</string> + <string name="for_episodes">Para episodios</string> + <string name="suggestions_for">Sugerencias para %1$s</string> + <string name="increase_all_cards_size">Aumentar tamaño de todas las tarjetas</string> + <string name="decrease_all_cards_size">Reducir tamaño de todas las tarjetas</string> + <string name="use_thumb_images">Usar miniaturas</string> + <string name="settings_saved">Ajustes guardados</string> + <string name="display_presets">Preajustes de visualización</string> + <string name="display_presets_description">Preajustes integrados para estilizar todas las filas</string> + <string name="customize_home_summary">Elegir filas e imágenes en la página de inicio</string> + <string name="recently_released_in">Lanzado recientemente en %1$s</string> + <string name="favorite_items">Favoritos%s</string> + <string name="content_scale_fit">Ajustar</string> + <string name="content_scale_crop">Recortar</string> + <string name="content_scale_fill">Rellenar</string> + <string name="content_scale_fill_width">Ajustar al ancho</string> + <string name="content_scale_fill_height">Ajustar al alto</string> + <string name="display_preset_series_thumb">Miniaturas de las series</string> + <string name="display_preset_episode_thumbnails">Miniaturas de los episodios</string> + <string name="volume_lowest">Mínimo</string> + <string name="volume_low">Bajo</string> + <string name="volume_medium">Medio</string> + <string name="volume_high">Alto</string> + <string name="volume_full">Máximo</string> + <string name="purple">Morado</string> + <string name="orange">Naranja</string> + <string name="bold_blue">Azul intenso</string> + <string name="black">Negro</string> + <string name="display_preset_default">Póster</string> + <string name="display_preset_compact">Póster compacto</string> + <string name="skip_ignore">Ignorar</string> + <string name="skip_automatically">Omitir automáticamente</string> + <string name="skip_ask">Preguntar para omitir</string> + <string name="ffmpeg_fallback">Usar FFmpeg solo si no existe un decodificador integrado</string> + <string name="ffmpeg_prefer">Preferir FFmpeg sobre los decodificadores integrados</string> + <string name="ffmpeg_never">No usar nunca decodificadores FFmpeg</string> + <string name="next_up_playback_end">Al finalizar la reproducción</string> + <string name="next_up_outro">Durante los créditos finales</string> + <string name="white">Blanco</string> + <string name="light_gray">Gris claro</string> + <string name="dark_gray">Gris oscuro</string> + <string name="yellow">Amarillo</string> + <string name="cyan">Cian</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contorno</string> + <string name="subtitle_edge_shadow">Sombra</string> + <string name="background_style_wrap">Ajustado al texto</string> + <string name="background_style_boxed">Enmarcado</string> + <string name="prefer_mpv">Preferir MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Usar ExoPlayer para reproducir contenido HDR</string> + <string name="aspect_ratios_poster">Póster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Cuadrada (1:1)</string> + <string name="image_type_primary">Primaria</string> + <string name="image_type_thumb">Miniatura</string> + <string name="backdrop_style_dynamic">Imagen con color dinámico</string> + <string name="backdrop_style_image">Imagen</string> + <string name="enter_url_api_key"><![CDATA[Enter URL & API Key]]></string> + <string name="api_key">Clave API</string> + <string name="seerr_login">Iniciar sesión en el servidor Seerr</string> + <string name="seerr_jellyfin_user">Usuario de Jellyfin</string> + <string name="seerr_local_user">Usuario local</string> + <string name="search_and_download_subtitles"><![CDATA[Buscar y descargar subtítulos]]></string> + <string name="no_subtitles_found">No se encontraron subtítulos externos</string> + <string name="series_continueing">Actualidad</string> </resources> diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 765c5fe2..10b611eb 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -22,7 +22,7 @@ <string name="confirm">Kinnita</string> <string name="continue_watching">Jätka vaatamist</string> <string name="critic_rating">Kriitikute hinnang</string> - <string name="decimal_seconds">%.1f sekundit</string> + <string name="decimal_seconds">%.2f sekundit</string> <string name="default_track">Vaikimisi</string> <string name="delete">Kustuta</string> <string name="died">Surmaaeg</string> @@ -406,4 +406,61 @@ <string name="retry">Proovi uuesti</string> <string name="software_decoding_av1">AV1 tarkvaraline dekodeerimine</string> <string name="upgrade_mpv_toast">MPV on nüüd vaikimisi meediaesitaja kõige v.a. HDR-i jaoks.\nSeda saad muuta seadistustest.</string> + <string name="hdr_subtitle_style">HDR-i tiitrite stiil</string> + <string name="image_subtitle_opacity">Pildiliste tiitrite läbipaistvus</string> + <string name="brightness">Eredus</string> + <string name="contrast">Kontrastsus</string> + <string name="saturation">Küllastus</string> + <string name="hue">Värvitoon</string> + <string name="red">Punane</string> + <string name="green">Roheline</string> + <string name="blue">Sinine</string> + <string name="blur">Hägustamine</string> + <string name="save_for_album">Salvesta albumi jaoks</string> + <string name="play_slideshow">Näita slaidiesitlust</string> + <string name="stop_slideshow">Peata slaidiesitlus</string> + <string name="slideshow_at_beginning">Alguses</string> + <string name="no_more_images">Rohkem fotosid pole</string> + <string name="rotate_left">Pööra vasakule</string> + <string name="rotate_right">Pööra paremale</string> + <string name="zoom_in">Suumi sisse</string> + <string name="zoom_out">Suumi välja</string> + <string name="slideshow_duration">Slaidiesitluse kestus</string> + <string name="play_videos_during_slideshow">Slaidiesitluse ajal esita videoid</string> + <plurals name="days"> + <item quantity="one">%s päev</item> + <item quantity="other">%s päeva</item> + </plurals> + <string name="quick_connect">Kiirühendus</string> + <string name="quick_connect_summary">Luba muul seadmel logida sinu kasutajakontoga sisse</string> + <string name="quick_connect_code">Sisesta kiirühenduse kood</string> + <string name="quick_connect_code_error">Kood peab olema kuuenumbriline</string> + <string name="quick_connect_success">Seadme autentimine õnnestus</string> + <string name="send_media_info_log_to_server">Salvesta meediumi infologi serverisse</string> + <string name="no_limit">Piirangut pole</string> + <string name="max_days_next_up">Järgmisena esitamisel sisu päevade ülempiir</string> + <string name="add_row">Lisa rida</string> + <string name="genres_in">Žanrid: %1$s</string> + <string name="recently_released_in">Hiljuti avaldatud: %1$s</string> + <string name="height">Kõrgus</string> + <string name="apply_all_rows">Rakenda kõikidele ridadele</string> + <string name="customize_home">Kohanda kodulehte</string> + <string name="home_rows">Kodulehe/avalehe read</string> + <string name="load_from_server">Laadi kasutajaprofiil serverist</string> + <string name="save_to_server">Salvesta kasutajaprofiil serverisse</string> + <string name="load_from_web_client">Laadi veebikliendist</string> + <string name="use_series">Kasuta sarja pilti</string> + <string name="add_row_for">Lisa rida: %1$s</string> + <string name="overwrite_server_settings">Kas kirjutad serveris leiduvad seadistused üle?</string> + <string name="overwrite_local_settings">Kas kirjutad kohalikud seadistused üle?</string> + <string name="for_episodes">Episoodide jaoks</string> + <string name="suggestions_for">Soovitused: %1$s</string> + <string name="increase_all_cards_size">Suurenda suurust kõikide kaartide jaoks</string> + <string name="decrease_all_cards_size">Vähenda suurust kõikide kaartide jaoks</string> + <string name="use_thumb_images">Kasuta pisipilte</string> + <string name="settings_saved">Seadistused on salvesatatud</string> + <string name="display_presets">Kuvamise eelseadistused</string> + <string name="display_presets_description">Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks</string> + <string name="customize_home_summary">Vali kodulehe/avalehe read ja pildid</string> + <string name="favorite_items">Märgi %s lemmikuks</string> </resources> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> +</resources> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a53c580f..538fd523 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -24,7 +24,7 @@ <string name="community_rating">Note de la communauté</string> <string name="compose_error_message_title">Une erreur est survenue ! Appuyez sur le bouton pour envoyer les logs à votre serveur.</string> <string name="critic_rating">Note des critiques</string> - <string name="decimal_seconds">%.1f secondes</string> + <string name="decimal_seconds">%.2f secondes</string> <string name="died">Décédé(e)</string> <string name="disabled">Désactivé</string> <string name="downloading">Téléchargement en cours…</string> @@ -422,4 +422,62 @@ <string name="request_4k">Demande en 4K</string> <string name="software_decoding_av1">Décodage logiciel AV1</string> <string name="upgrade_mpv_toast">MPV est désormais le lecteur par défaut, sauf pour HDR.\nVous pouvez modifier cela dans les paramètres.</string> + <string name="hdr_subtitle_style">Style de sous-titres HDR</string> + <string name="image_subtitle_opacity">opacité du sous-titre de l\'image</string> + <string name="brightness">Luminosité</string> + <string name="contrast">Contraste</string> + <string name="saturation">Saturation</string> + <string name="hue">Teinte</string> + <string name="red">Rouge</string> + <string name="green">Vert</string> + <string name="blue">Bleu</string> + <string name="blur">Flou</string> + <string name="save_for_album">Enregistrer pour l\'album</string> + <string name="play_slideshow">Lire le diaporama</string> + <string name="stop_slideshow">Arrêter le diaporama</string> + <string name="slideshow_at_beginning">Au début</string> + <string name="no_more_images">Plus de photos</string> + <string name="rotate_left">Faire pivoter à gauche</string> + <string name="rotate_right">Faire pivoter à droite</string> + <string name="zoom_in">Zoomer</string> + <string name="zoom_out">Dézoomer</string> + <string name="slideshow_duration">Durée du diaporama</string> + <string name="play_videos_during_slideshow">Diffuser des vidéos pendant le diaporama</string> + <plurals name="days"> + <item quantity="one">%s jour</item> + <item quantity="many">%s jours</item> + <item quantity="other">%s jours</item> + </plurals> + <string name="quick_connect">Connexion rapide</string> + <string name="quick_connect_summary">Autoriser un autre appareil à se connecter à votre compte</string> + <string name="quick_connect_code">Entrez le code de connexion rapide</string> + <string name="quick_connect_code_error">Le code doit comporter 6 chiffres</string> + <string name="quick_connect_success">Appareil autorisé avec succès</string> + <string name="send_media_info_log_to_server">Envoyer le journal des informations multimédias au serveur</string> + <string name="no_limit">Aucune limite</string> + <string name="max_days_next_up">Nombre maximal de jours dans Prochainement</string> + <string name="add_row">Ajouter une ligne</string> + <string name="genres_in">Genres en %1$s</string> + <string name="recently_released_in">Récemment publié en %1$s</string> + <string name="height">Hauteur</string> + <string name="apply_all_rows">S\'applique à toutes les lignes</string> + <string name="customize_home">Personnaliser la page d\'accueil</string> + <string name="home_rows">Lignes d\'accueil</string> + <string name="load_from_server">Charger à partir du profil utilisateur du serveur</string> + <string name="save_to_server">Enregistrer dans le profil utilisateur du serveur</string> + <string name="load_from_web_client">Charger à partir du client Web</string> + <string name="use_series">Utiliser l\'image de la série</string> + <string name="add_row_for">Ajouter une ligne pour %1$s</string> + <string name="overwrite_server_settings">Écraser les paramètres sur le serveur ?</string> + <string name="overwrite_local_settings">Écraser les paramètres locaux ?</string> + <string name="for_episodes">Pour les épisodes</string> + <string name="suggestions_for">Suggestions pour %1$s</string> + <string name="increase_all_cards_size">Augmenter la taille pour toutes les cartes</string> + <string name="decrease_all_cards_size">Diminuer la taille pour toutes les cartes</string> + <string name="use_thumb_images">Utilisez des images de miniatures</string> + <string name="settings_saved">Paramètres enregistrés</string> + <string name="display_presets">Afficher les préréglages</string> + <string name="display_presets_description">Préréglages intégrés pour styliser rapidement toutes les lignes</string> + <string name="customize_home_summary">Choisissez des lignes et des images sur la page d\'accueil</string> + <string name="favorite_items">Favori %s</string> </resources> diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index c1bf5242..356732cd 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -23,7 +23,7 @@ <string name="confirm">Konfirmasi</string> <string name="continue_watching">Lanjutkan menonton</string> <string name="critic_rating">Penilaian Kritis</string> - <string name="decimal_seconds">%.1f detik</string> + <string name="decimal_seconds">%.2f detik</string> <string name="default_track">Bawaan</string> <string name="delete">Hapus</string> <string name="died">Meninggal</string> @@ -389,4 +389,61 @@ <string name="request">Minta</string> <string name="pending">Tertunda</string> <string name="software_decoding_av1">Dekode perangkat lunak AV1</string> + <string name="upgrade_mpv_toast">MPV sekarang adalah pemutar bawaan kecuali konten HDR.\nPerubahan dapat dilakukan di menu pengaturan.</string> + <string name="hdr_subtitle_style">Gaya subtitel HDR</string> + <string name="image_subtitle_opacity">Opasitas subtitel gambar</string> + <string name="brightness">Kecerahan</string> + <string name="contrast">Kontras</string> + <string name="saturation">Saturasi</string> + <string name="hue">Hue</string> + <string name="red">Merah</string> + <string name="green">Hijau</string> + <string name="blue">Biru</string> + <string name="blur">Kabur</string> + <string name="save_for_album">Simpan ke album</string> + <string name="play_slideshow">Putar tayangan slide</string> + <string name="stop_slideshow">Hentikan tayangan slide</string> + <string name="slideshow_at_beginning">Di awal</string> + <string name="no_more_images">Tidak ada foto lagi</string> + <string name="rotate_left">Putar kiri</string> + <string name="rotate_right">Putar kanan</string> + <string name="zoom_in">Perbesar</string> + <string name="zoom_out">Perkecil</string> + <string name="slideshow_duration">Durasi tayangan slide</string> + <string name="play_videos_during_slideshow">Putar video selama tayangan slide</string> + <string name="send_media_info_log_to_server">Kirim log info media ke server</string> + <plurals name="days"> + <item quantity="other">%s hari</item> + </plurals> + <string name="quick_connect">Koneksi Cepat</string> + <string name="quick_connect_summary">Izinkan perangkat lain untuk masuk ke akun kamu</string> + <string name="quick_connect_code">Masukkan kode Koneksi Cepat</string> + <string name="quick_connect_code_error">Kode harus terdiri dari 6 digit</string> + <string name="quick_connect_success">Perangkat berhasil diizinkan</string> + <string name="no_limit">Tanpa batasan</string> + <string name="max_days_next_up">Batas hari untuk Berikutnya</string> + <string name="add_row">Tambah baris</string> + <string name="genres_in">Genre di %1$s</string> + <string name="recently_released_in">Baru saja dirilis di %1$s</string> + <string name="height">Tinggi</string> + <string name="apply_all_rows">Terapkan ke semua baris</string> + <string name="customize_home">Kustomisasi beranda</string> + <string name="home_rows">Baris beranda</string> + <string name="load_from_server">Muat dari profil pengguna server</string> + <string name="save_to_server">Simpan ke profil pengguna server</string> + <string name="load_from_web_client">Muat dari klien web</string> + <string name="use_series">Gunakan gambar seri</string> + <string name="add_row_for">Tambah baris untuk %1$s</string> + <string name="overwrite_server_settings">Timpa pengaturan di server?</string> + <string name="overwrite_local_settings">Timpa pengaturan lokal?</string> + <string name="for_episodes">Untuk episode</string> + <string name="suggestions_for">Rekomendasi untuk %1$s</string> + <string name="increase_all_cards_size">Perbesar ukuran semua kartu</string> + <string name="decrease_all_cards_size">Perkecil ukuran semua kartu</string> + <string name="use_thumb_images">Gunakan gambar thumbnail</string> + <string name="settings_saved">Pengaturan disimpan</string> + <string name="display_presets">Preset tampilan</string> + <string name="display_presets_description">Preset bawaan untuk kustomisasi cepat semua baris</string> + <string name="customize_home_summary">Pilih baris dan gambar di halaman beranda</string> + <string name="favorite_items">Favoritkan %s</string> </resources> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 15a70f3d..a3764b96 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -104,7 +104,7 @@ <string name="tv_guide">Guida TV</string> <string name="show">Mostra</string> <string name="searching">Ricerca in corso…</string> - <string name="decimal_seconds">%.1f secondi</string> + <string name="decimal_seconds">%.2f secondi</string> <string name="commercial">Commerciale</string> <string name="community_rating">Valutazione community</string> <string name="critic_rating">Valutazione della critica</string> @@ -422,4 +422,62 @@ <string name="voice_error_timeout">Riconoscimento vocale scaduto</string> <string name="software_decoding_av1">Decodifica software AV1</string> <string name="upgrade_mpv_toast">MPV è ora il lettore predefinito, eccetto per i contenuti HDR.\nPuoi modificarlo nelle impostazioni.</string> + <string name="hdr_subtitle_style">Stile sottotitoli HDR</string> + <string name="image_subtitle_opacity">Opacità sottotitoli immagine</string> + <string name="brightness">Luminosità</string> + <string name="contrast">Contrasto</string> + <string name="saturation">Saturazione</string> + <string name="hue">Tinta</string> + <string name="red">Rosso</string> + <string name="green">Verde</string> + <string name="blue">Blu</string> + <string name="blur">Sfocatura</string> + <string name="save_for_album">Salva per l\'album</string> + <string name="play_slideshow">Riproduci slideshow</string> + <string name="stop_slideshow">Ferma slideshow</string> + <string name="slideshow_at_beginning">Dall\'inizio</string> + <string name="no_more_images">Nessun\'altra foto</string> + <string name="rotate_left">Ruota a sinistra</string> + <string name="rotate_right">Ruota a destra</string> + <string name="zoom_in">Ingrandisci</string> + <string name="zoom_out">Rimpicciolisci</string> + <string name="slideshow_duration">Durata slideshow</string> + <string name="play_videos_during_slideshow">Riproduci video durante lo slideshow</string> + <string name="send_media_info_log_to_server">Invia log informazioni media al server</string> + <plurals name="days"> + <item quantity="one">%s giorno</item> + <item quantity="many">%s giorni</item> + <item quantity="other">%s giorni</item> + </plurals> + <string name="no_limit">Nessun limite</string> + <string name="max_days_next_up">Giorni massimi in Prossimo</string> + <string name="quick_connect">Connessione rapida</string> + <string name="quick_connect_summary">Autorizza un altro dispositivo per accedere al tuo account</string> + <string name="quick_connect_code">Inserisci il codice di Connessione rapida</string> + <string name="quick_connect_code_error">Il codice deve essere di 6 cifre</string> + <string name="quick_connect_success">Dispositivo autorizzato con successo</string> + <string name="add_row">Aggiungi riga</string> + <string name="genres_in">Generi in %1$s</string> + <string name="recently_released_in">Recentemente rilasciato in %1$s</string> + <string name="height">Altezza</string> + <string name="apply_all_rows">Applica a tutte le righe</string> + <string name="load_from_server">Carica dal profilo utente del server</string> + <string name="add_row_for">Aggiungi riga per %1$s</string> + <string name="overwrite_server_settings">Sovrascrivere le impostazioni sul server?</string> + <string name="overwrite_local_settings">Sovrascrivere le impostazioni locali?</string> + <string name="suggestions_for">Suggerimenti per %1$s</string> + <string name="increase_all_cards_size">Aumenta dimensione di tutte le card</string> + <string name="decrease_all_cards_size">Riduci dimensione di tutte le card</string> + <string name="use_thumb_images">Usa immagini thumbnail</string> + <string name="settings_saved">Impostazioni salvate</string> + <string name="display_presets">Mostra preset</string> + <string name="display_presets_description">Preset integrati per stilizzare rapidamente tutte le righe</string> + <string name="home_rows">Righe home</string> + <string name="for_episodes">Per gli episodi</string> + <string name="customize_home">Personalizza home page</string> + <string name="save_to_server">Salva nel profilo utente del server</string> + <string name="load_from_web_client">Carica dal client web</string> + <string name="use_series">Usa immagine delle serie</string> + <string name="customize_home_summary">Scegli righe e immagini nella home page</string> + <string name="favorite_items">Preferito %s</string> </resources> diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 5b39797f..d892ebf3 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -122,7 +122,7 @@ <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailere</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> <string name="tv_guide">Programoversikt</string> @@ -155,47 +155,47 @@ <string name="extras">Ekstramateriale</string> <plurals name="other_extras"> <item quantity="one">Annet</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bak kulissene</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="theme_songs"> <item quantity="one">Temasanger</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="theme_videos"> <item quantity="one">Temavideoer</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="clips"> <item quantity="one">Klipp</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Slettede scener</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="interviews"> <item quantity="one">Intervjuer</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="scenes"> <item quantity="one">Scener</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="samples"> <item quantity="one">Smakebiter</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="featurettes"> <item quantity="one">Bakomfilmer</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="shorts"> <item quantity="one">Kortfilmer</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="downloads"> <item quantity="one">%s nedlasting</item> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 69a8f90a..53cd49fc 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -45,9 +45,9 @@ <string name="trailer">Zwiastun</string> <plurals name="trailers"> <item quantity="one">Zwiastuny</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="submit">Potwierdź</string> <string name="sort_by_date_released">Data Wydania</string> @@ -122,21 +122,21 @@ <string name="one_click_pause_summary_on">Wciśnij środek by pauzować/wznowić</string> <plurals name="interviews"> <item quantity="one">Wywiady</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="scenes"> <item quantity="one">Sceny</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="theme_videos"> <item quantity="one">Czołówki</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="create_playlist">Utwórz nową playliste</string> <string name="add_to_playlist">Dodaj do playlisty</string> @@ -182,9 +182,9 @@ <string name="italic_font">Czcionka kursywy</string> <plurals name="behind_the_scenes"> <item quantity="one">Za kulisami</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="advanced_settings">Ustawienia zaawansowane</string> <string name="check_for_updates">Sprawdź dostępność aktualizacji</string> @@ -270,15 +270,15 @@ <string name="extras">Dodatki</string> <plurals name="other_extras"> <item quantity="one">Inne</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Usunięte sceny</item> - <item quantity="few"></item> - <item quantity="many"></item> - <item quantity="other"></item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> </plurals> <string name="nav_drawer_switch_on_focus_summary_off">Naciśnij by zmienić stronę</string> <string name="send_app_logs_summary">Przydatne do debugowania</string> 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..76bf308f --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,463 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">Sobre</string> + <string name="active_recordings">Gravações ativas</string> + <string name="add_favorite">Favoritar</string> + <string name="add_server">Adicionar Servidor</string> + <string name="add_user">Adicionar Usuário</string> + <string name="audio">Áudio</string> + <string name="birthplace">Local de nascimento</string> + <string name="bitrate">Taxa de bits</string> + <string name="born">Nascido</string> + <string name="cancel_recording">Cancelar gravação</string> + <string name="cancel_series_recording">Cancelar gravação da série</string> + <string name="cancel">Cancelar</string> + <string name="chapters">Capítulos</string> + <string name="choose_stream">Escolha %1$s</string> + <string name="clear_image_cache">Limpar cache de imagens</string> + <string name="collection">Coleção</string> + <string name="collections">Coleções</string> + <string name="commercial">Comerciais</string> + <string name="community_rating">Avaliação da comunidade</string> + <string name="compose_error_message_title">Ocorreu um erro! Aperte o botão para enviar os logs para seu servidor.</string> + <string name="confirm">Confirmar</string> + <string name="continue_watching">Continue assistindo</string> + <string name="critic_rating">Avaliação dos críticos</string> + <string name="decimal_seconds">%.2f segundos</string> + <string name="default_track">Padrão</string> + <string name="delete">Remover</string> + <string name="died">Falecido</string> + <string name="directed_by">Dirigido por %1$s</string> + <string name="director">Diretor</string> + <string name="disabled">Desabilitado</string> + <string name="discovered_servers">Servidores encontrados</string> + <string name="downloading">Baixando…</string> + <string name="enabled">Habilitado</string> + <string name="ends_at">Termina às %1$s</string> + <string name="enter_server_url">Insira o IP ou URL do Servidor</string> + <string name="enter_server_address">Insira o endereço do servidor</string> + <string name="episodes">Episódios</string> + <string name="error_loading_collection">Erro ao carregar a coleção %1$s</string> + <string name="external_track">Externo</string> + <string name="favorites">Favoritos</string> + <string name="file_size">Tamanho</string> + <string name="forced_track">Forçado</string> + <string name="genres">Gêneros</string> + <string name="go_to_series">Ir para séries</string> + <string name="go_to">Ir Para</string> + <string name="hide_controller_timeout">Ocultar controles de reprodução</string> + <string name="hide_debug_info">Ocultar informações de depuração</string> + <string name="hide">Ocultar</string> + <string name="home">Início</string> + <string name="immediate">Imediato</string> + <string name="intro">Introdução</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="library">Biblioteca</string> + <string name="license_info">Informação de licenças</string> + <string name="live_tv">TV ao Vivo</string> + <string name="loading">Carregando…</string> + <string name="mark_entire_series_as_played">Marcar série inteira como reproduzida?</string> + <string name="mark_entire_series_as_unplayed">Marcar séria inteira como não reproduzida?</string> + <string name="mark_unwatched">Marcar como não assistida</string> + <string name="mark_watched">Marcar como assistida</string> + <string name="max_bitrate">Taxa de bits máxima</string> + <string name="more_like_this">Mais como isso</string> + <string name="more">Mais</string> + <plurals name="movies"> + <item quantity="one">Filmes</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="name">Nome</string> + <string name="next_up">A Seguir</string> + <string name="no_data">Sem dados</string> + <string name="no_results">Sem resultados</string> + <string name="no_servers_found">Nenhum servidor encontrado</string> + <string name="no_scheduled_recordings">Nenhuma gravação agendada</string> + <string name="no_update_available">Nenhuma atualização disponível</string> + <string name="none">Nenhum(a)</string> + <string name="only_forced_subtitles">Apenas Legendas Forçadas</string> + <string name="outro">Finalização</string> + <string name="path">Caminho</string> + <plurals name="people"> + <item quantity="one">Pessoas</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="play_count">Contagem de Reproduções</string> + <string name="play_from_here">Reproduzir a partir daqui</string> + <string name="play">Reproduzir</string> + <string name="playback_debug_info">Mostrar informações de depuração da reprodução</string> + <string name="playback_speed">Velocidade de Reprodução</string> + <string name="playback">Reprodução</string> + <string name="playlist">Lista de reprodução</string> + <string name="playlists">Listas de reprodução</string> + <string name="preview">Prévia</string> + <string name="profile_specific_settings">Configurações do Perfil de Usuário</string> + <string name="queue">Fila</string> + <string name="recap">Recapitulação</string> + <string name="recently_added_in">Recentemente adicionado em %1$s</string> + <string name="recently_added">Recentemente adicionado</string> + <string name="recently_recorded">Recentemente Gravado</string> + <string name="recently_released">Recentemente Publicado</string> + <string name="recommended">Recomendado</string> + <string name="record_program">Gravar Programa</string> + <string name="record_series">Gravar Série</string> + <string name="remove_favorite">Desfavoritar</string> + <string name="restart">Reiniciar</string> + <string name="resume">Continuar</string> + <string name="save">Salvar</string> + <string name="search">Procurar</string> + <string name="searching">Procurando…</string> + <string name="voice_search">Pesquisa por voz</string> + <string name="voice_starting">Iniciando</string> + <string name="voice_search_prompt">Fale para pesquisar</string> + <string name="processing">Processando</string> + <string name="press_back_to_cancel">Aperte voltar para cancelar</string> + <string name="voice_error_audio">Erro de gravação de áudio</string> + <string name="voice_error_client">Erro de reconhecimento de voz</string> + <string name="voice_error_permissions">Permissão de microfone necessária</string> + <string name="voice_error_network">Erro de rede</string> + <string name="voice_error_network_timeout">Tempo limite de rede esgotado</string> + <string name="voice_error_no_match">Nenhuma fala reconhecida</string> + <string name="voice_error_busy">Reconhecimento de voz ocupado</string> + <string name="voice_error_server">Erro de servidor</string> + <string name="voice_error_speech_timeout">Nenhuma fala detectada</string> + <string name="voice_error_unknown">Erro desconhecido</string> + <string name="voice_error_start_failed">Falha no início do reconhecimento</string> + <string name="voice_error_timeout">Tempo limite do reconhecimento de voz esgotado</string> + <string name="retry">Tentar novamente</string> + <string name="select_server">Selecionar Servidor</string> + <string name="select_user">Selecionar Usuário</string> + <string name="show_debug_info">Mostrar informações de depuração</string> + <string name="show_next_up_when">Mostrar próximo</string> + <string name="show">Mostrar</string> + <string name="shuffle">Aleatório</string> + <string name="skip">Pular</string> + <string name="sort_by_date_added">Data de Adição</string> + <string name="sort_by_date_episode_added">Data de Adição do Episódio</string> + <string name="sort_by_date_played">Data de Reprodução</string> + <string name="sort_by_date_released">Data de Lançamento</string> + <string name="sort_by_name">Nome</string> + <string name="sort_by_random">Aleatório</string> + <string name="studios">Estúdios</string> + <string name="submit">Enviar</string> + <string name="subtitle_download_too_long">O download está demorando muito, talvez você tenha que reiniciar a reprodução</string> + <string name="subtitle">Legenda</string> + <string name="subtitles">Legendas</string> + <string name="suggestions">Sugestões</string> + <string name="switch_servers">Trocar de servidores</string> + <string name="switch_user">Trocar</string> + <string name="top_unwatched">Não assistidos com melhor avaliação</string> + <string name="trailer">Trailer</string> + <plurals name="trailers"> + <item quantity="one">Trailers</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="tv_dvr_schedule">Agenda do DVR</string> + <string name="tv_guide">Guia</string> + <string name="tv_season">Temporada</string> + <string name="tv_seasons">Temporadas</string> + <plurals name="tv_shows"> + <item quantity="one">Programas de TV</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="ui_interface">Interface</string> + <string name="unknown">Desconhecido</string> + <string name="updates">Atualizações</string> + <string name="version">Versão</string> + <string name="video_scale">Escala de Vídeo</string> + <string name="video">Vídeo</string> + <string name="videos">Vídeos</string> + <string name="watch_live">Assista ao vivo</string> + <string name="years_old">%1$d anos de idade</string> + <string name="play_with_transcoding">Reproduzir com transcodificação</string> + <string name="media_information">Informação de Mídia</string> + <string name="show_clock">Mostrar Relógio</string> + <string name="aired_episode_order">Ordem de Lançamento do Episódio</string> + <string name="create_playlist">Criar nova lista de reprodução</string> + <string name="add_to_playlist">Adicionar a lista de reprodução</string> + <string name="one_click_pause">Pausar com um clique</string> + <string name="one_click_pause_summary_on">Aperte o centro do controle direcional para pausar/reproduzir</string> + <string name="italic_font">Colocar a fonte em itálico</string> + <string name="font">Fonte</string> + <string name="background">Plano de fundo</string> + <string name="success">Sucesso</string> + <string name="official_rating">Classificação Parental</string> + <string name="runtime_sort">Duração</string> + <string name="extras">Extras</string> + <plurals name="other_extras"> + <item quantity="one">Outro</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">Por Trás das Cenas</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Músicas Tema</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">Vídeos Tema</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="clips"> + <item quantity="one">Clipes</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="one">Cenas Deletadas</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="interviews"> + <item quantity="one">Entrevistas</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="scenes"> + <item quantity="one">Cenas</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="samples"> + <item quantity="one">Amostras</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="shorts"> + <item quantity="one">Curtas</item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="favorite_items">Favoritar %s</string> + <plurals name="downloads"> + <item quantity="one">%s download</item> + <item quantity="many">%s downloads</item> + <item quantity="other">%s downloads</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d hora</item> + <item quantity="many">%d horas</item> + <item quantity="other">%d horas</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s dia</item> + <item quantity="many">%s dias</item> + <item quantity="other">%s dias</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d item</item> + <item quantity="many">%d itens</item> + <item quantity="other">%d itens</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d segundo</item> + <item quantity="many">%d segundos</item> + <item quantity="other">%d segundos</item> + </plurals> + <string name="ac3_supported">Dispositivo suporta AC3/Dolby Digital</string> + <string name="advanced_settings">Configurações Avançadas</string> + <string name="advanced_ui">IU Avançada</string> + <string name="app_theme">Tema do Aplicativo</string> + <string name="auto_check_for_updates">Checar atualizações automaticamente</string> + <string name="auto_play_next_delay">Atraso antes da próxima reprodução</string> + <string name="auto_play_next">Reproduzir próximo automaticamente</string> + <string name="check_for_updates">Checar atualizações</string> + <string name="combine_continue_next_summary">Aplicar apenas a Séries de TV</string> + <string name="direct_play_ass">Reproduzir legendas ASS diretamente</string> + <string name="direct_play_pgs">Reproduzir legendas PGS diretamente</string> + <string name="downmix_stereo">Sempre mixar para estéreo</string> + <string name="ffmpeg_extension_pref">Usar módulo de decodificação do FFmpeg</string> + <string name="global_content_scale">Escala padrão do conteúdo</string> + <string name="install_update">Instalar atualização</string> + <string name="installed_version">Versão instalada</string> + <string name="max_homepage_items">Máximo de itens nas linhas da página inicial</string> + <string name="nav_drawer_pins_summary">Escolher itens exibidos por padrão, os outros serão ocultados</string> + <string name="nav_drawer_pins">Personalizar os Itens da Gaveta de Navegação</string> + <string name="nav_drawer_switch_on_focus_summary_off">Clicar para trocar de páginas</string> + <string name="nav_drawer_switch_on_focus">Trocar a página de navegação no foco</string> + <string name="pass_out_protection">Proteção Contra Desmaios</string> + <string name="play_theme_music">Tocar música tema</string> + <string name="playback_overrides">Sobreposições de reprodução</string> + <string name="remember_selected_tab">Lembrar abas selecionadas</string> + <string name="rewatch_next_up">Habilitar reassistir em a seguir</string> + <string name="seek_bar_steps">Passos da barra de busca</string> + <string name="send_app_logs_summary">Útil para depuração</string> + <string name="send_app_logs">Enviar os registros do aplicativo para o servidor atual</string> + <string name="send_crash_reports_summary">Tentativa de envio para o último servidor conectado</string> + <string name="send_crash_reports">Enviar Relatórios de Falhas</string> + <string name="settings">Configurações</string> + <string name="skip_commercials_behavior">Comportamento de pular os comerciais</string> + <string name="skip_intro_behavior">Comportamento de pular as introduções</string> + <string name="skip_outro_behavior">Comportamento de pular a finalização</string> + <string name="skip_previews_behavior">Comportamento de pular as prévias</string> + <string name="skip_recap_behavior">Comportamento de pular as recapitulações</string> + <string name="update_available">Atualização disponível</string> + <string name="update_url_summary">URL usada para verificação de atualizações</string> + <string name="update_url">URL de atualização</string> + <string name="font_size">Tamanho da fonte</string> + <string name="font_color">Cor da fonte</string> + <string name="font_opacity">Opacidade da fonte</string> + <string name="edge_style">Estilo da borda</string> + <string name="edge_color">Cor da borda</string> + <string name="background_opacity">Opacidade do fundo</string> + <string name="background_style">Estilo do fundo</string> + <string name="subtitle_style">Estilo da legenda</string> + <string name="background_color">Cor do fundo</string> + <string name="reset">Redefinir</string> + <string name="bold_font">Fonte em negrito</string> + <string name="player_backend">Backend de reprodução</string> + <string name="mpv_hardware_decoding">MPV: Usar decodificação baseada em hardware</string> + <string name="disable_if_crash">Desabilite se encontrar falhas</string> + <string name="mpv_options">Opções do MPV</string> + <string name="exoplayer_options">Opções do ExPlayer</string> + <string name="skip_segment_unknown">Pular Segmento</string> + <string name="skip_segment_commercial">Pular Anúncios</string> + <string name="skip_segment_preview">Pular Prévia</string> + <string name="skip_segment_recap">Pular Recapitulação</string> + <string name="skip_segment_outro">Pular Finalização</string> + <string name="skip_segment_intro">Pular Introdução</string> + <string name="played">Reproduzido</string> + <string name="filter">Filtro</string> + <string name="year">Ano</string> + <string name="decade">Década</string> + <string name="remove">Remover</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="mpv_use_gpu_next">MPV: Usar gpu-next</string> + <string name="mpv_conf">Editar mpv.conf</string> + <string name="discard_change">Descartar mudanças?</string> + <string name="subtitle_margin">Margem</string> + <string name="verbose_logging">Registro extenso</string> + <string name="enter_pin">Insira o PIN</string> + <string name="sign_in_auto">Entrar automaticamente</string> + <string name="use_server_credentials">Autenticar via servidor</string> + <string name="press_enter_to_confirm">Aperte o botão central para confirmar</string> + <string name="require_pin_code">Exigir PIN para o perfil</string> + <string name="confirm_pin">Confirmar o PIN</string> + <string name="incorrect">Incorreto</string> + <string name="pin_too_short">PIN deve ter 4 ou mais dígitos</string> + <string name="will_remove_pin">Irá remover o PIN</string> + <string name="image_cache_size">Tamanho do cache de imagens no disco (MB)</string> + <string name="view_options">Ver opções</string> + <string name="columns">Colunas</string> + <string name="spacing">Espaçamento</string> + <string name="aspect_ratio">Proporção da Tela</string> + <string name="show_details">Mostrar detalhes</string> + <string name="image_type">Tipo de imagem</string> + <string name="guest_stars">Estrelas convidadas</string> + <string name="edge_size">Tamanho da borda</string> + <string name="refresh_rate_switching">Troca de taxa de atualização</string> + <string name="automatic">Automático</string> + <string name="username_or_password">Usar nome de usuário / senha</string> + <string name="login">Autenticação</string> + <string name="general">Geral</string> + <string name="container">Contêiner</string> + <string name="title">Título</string> + <string name="codec">Codec</string> + <string name="profile">Perfil</string> + <string name="level">Nível</string> + <string name="resolution">Resolução</string> + <string name="anamorphic">Anamórfico</string> + <string name="interlaced">Entrelaçado</string> + <string name="framerate">Taxa de atualização</string> + <string name="bit_depth">Profundidade de bits</string> + <string name="video_range">Alcance de vídeo</string> + <string name="video_range_type">Tipo de alcance de vídeo</string> + <string name="color_space">Espaço de cores</string> + <string name="color_transfer">Transferência de cores</string> + <string name="color_primaries">Primárias de cores</string> + <string name="pixel_format">Formato de pixel</string> + <string name="ref_frames">Quadros de referência</string> + <string name="language">Idioma</string> + <string name="layout">Layout</string> + <string name="channels">Canais</string> + <string name="sample_rate">Taxa de amostra</string> + <string name="yes">Sim</string> + <string name="no">Não</string> + <string name="sdr">SDR</string> + <string name="hdr">HDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="bit_unit">bit</string> + <string name="sample_rate_unit">Hz</string> + <string name="show_titles">Mostrar títulos</string> + <string name="live_tv_repeat">Repetir</string> + <string name="favorite_channels_at_beginning">Mostrar canais favoritos primeiro</string> + <string name="sort_channels_recently_watched">Ordenar canais por recentemente assistidos</string> + <string name="color_code_programs">Codificar programas por cor</string> + <string name="subtitle_delay">Atraso na legenda</string> + <string name="backdrop_display">Estilo de fundo</string> + <string name="resolution_switching">Troca de resolução</string> + <string name="local">Local</string> + <string name="play_trailer">Reproduzir trailer</string> + <string name="no_trailers">Nenhum trailer</string> + <string name="clear_track_choices">Limpar escolhas de faixas</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="truehd">TrueHD</string> + <string name="dolby_digital">DD</string> + <string name="dolby_digital_plus">DD+</string> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Reprodução direta de Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Ignorar checagens de compatibilidade de dispositivo</string> + <string name="discover">Descobrir</string> + <string name="request">Solicitar</string> + <string name="pending">Pendente</string> + <string name="seerr_integration">Integração com o Seerr</string> + <string name="remove_seerr_server">Remover Servidor Seerr</string> + <string name="seerr_server_added">Servidor Seerr adicionado</string> + <string name="quick_connect">Conexão Rápida</string> + <string name="quick_connect_summary">Autorizar outro dispositivo a se autenticar com sua conta</string> + <string name="quick_connect_code">Insira o código de conexão rápida</string> + <string name="quick_connect_code_error">O código deve ter 6 dígitos</string> + <string name="quick_connect_success">Dispositivo autorizado com sucesso</string> + <string name="password">Senha</string> + <string name="username">Nome de usuário</string> + <string name="url">URL</string> + <string name="trending">Tendências</string> + <string name="upcoming_movies">Filmes Lançados em Breve</string> + <string name="upcoming_tv">Programas de TV Lançados em Breve</string> + <string name="request_4k">Solicitar em 4K</string> + <string name="software_decoding_av1">Decodificação de AV1 via software</string> + <string name="upgrade_mpv_toast">MPV é o reprodutor padrão, exceto para HDR\nVocê pode alterar isso nas configurações.</string> + <string name="hdr_subtitle_style">Estilo da legenda HDR</string> + <string name="image_subtitle_opacity">Opacidade da legenda de imagem</string> + <string name="brightness">Brilho</string> + <string name="contrast">Contraste</string> + <string name="saturation">Saturação</string> + <string name="hue">Matiz</string> + <string name="red">Vermelho</string> + <string name="green">Verde</string> + <string name="blue">Azul</string> + <string name="blur">Desfoque</string> + <string name="play_slideshow">Reproduzir apresentação de slides</string> + <string name="stop_slideshow">Interromper apresentação de slides</string> + <string name="slideshow_at_beginning">No começo</string> + <string name="no_more_images">Sem mais fotos</string> + <string name="rotate_left">Girar a esquerda</string> + <string name="rotate_right">Girar a direita</string> + <string name="zoom_in">Mais zoom</string> + <string name="zoom_out">Menos zoom</string> + <string name="slideshow_duration">Duração da reprodução de slides</string> + <string name="play_videos_during_slideshow">Reproduzir vídeos ao longo da apresentação de slides</string> + <string name="send_media_info_log_to_server">Enviar registro de informações da mídia ao servidor</string> + <string name="no_limit">Sem limite</string> + <string name="max_days_next_up">Máximo de dias no A Seguir</string> + <string name="add_row">Adicionar linha</string> + <string name="genres_in">Gêneros em %1$s</string> + <string name="recently_released_in">Recentemente lançado em %1$s</string> + <string name="height">Altura</string> + <string name="apply_all_rows">Aplicar a todas as linhas</string> + <string name="customize_home">Personalizar página inicial</string> +</resources> diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3205d05d..1fb5fbb5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="about">O Wholphin</string> + <string name="about">Sobre</string> <string name="add_favorite">Favorito</string> <string name="add_server">Adicionar Servidor</string> <string name="add_user">Adicionar Utilizador</string> @@ -10,10 +10,10 @@ <string name="chapters">Capítulos</string> <string name="choose_stream">Escolher %1$s</string> <string name="clear_image_cache">Eliminar cache de imagens</string> - <string name="collection">Colecção</string> - <string name="collections">Colecções</string> + <string name="collection">Coleção</string> + <string name="collections">Coleções</string> <string name="confirm">Confirmar</string> - <string name="decimal_seconds">%.1f segundos</string> + <string name="decimal_seconds">%.2f segundos</string> <string name="default_track">Padrão</string> <string name="delete">Eliminar</string> <string name="directed_by">Realizado por %1$s</string> @@ -27,7 +27,7 @@ <string name="file_size">Tamanho</string> <string name="forced_track">Forçado</string> <string name="go_to">Ir para</string> - <string name="active_recordings">Gravações Activas</string> + <string name="active_recordings">Gravações Ativas</string> <string name="birthplace">Local de Nascimento</string> <string name="bitrate">Bitrate</string> <string name="born">Nascido</string> @@ -422,4 +422,61 @@ <string name="retry">Tentar novamente</string> <string name="software_decoding_av1">Decodificação de AV1 por software</string> <string name="upgrade_mpv_toast">O MPV é agora o reprodutor padrão, exceto para HDR.\nPode alterar esta configuração nas definições.</string> + <plurals name="days"> + <item quantity="one">%s Dia</item> + <item quantity="many">%s Dias</item> + <item quantity="other">%s Dias</item> + </plurals> + <string name="hdr_subtitle_style">Estilo de legenda HDR</string> + <string name="image_subtitle_opacity">Transparência da legenda da imagem</string> + <string name="brightness">Brilho</string> + <string name="contrast">Contraste</string> + <string name="saturation">Saturação</string> + <string name="hue">Matiz</string> + <string name="red">Vermelho</string> + <string name="green">Verde</string> + <string name="blue">Azul</string> + <string name="blur">Desfoque</string> + <string name="save_for_album">Guardar para o álbum</string> + <string name="play_slideshow">Reproduzir slides</string> + <string name="stop_slideshow">Parar slides</string> + <string name="slideshow_at_beginning">No início</string> + <string name="no_more_images">Não há mais fotos</string> + <string name="rotate_left">Rodar para a esquerda</string> + <string name="rotate_right">Rodar para a direita</string> + <string name="zoom_in">Ampliar</string> + <string name="zoom_out">Diminuir</string> + <string name="slideshow_duration">Duração dos slides</string> + <string name="play_videos_during_slideshow">Reproduzir vídeos durante a apresentação de slides</string> + <string name="send_media_info_log_to_server">Enviar registo de informações de mídia para o servidor</string> + <string name="no_limit">Sem limite</string> + <string name="max_days_next_up">Dias máximos em \'A Seguir\'</string> + <string name="quick_connect">Ligação Rápida</string> + <string name="quick_connect_summary">Autorize outro dispositivo a iniciar sessão na sua conta</string> + <string name="quick_connect_code">Insira o código de Ligação Rápida</string> + <string name="quick_connect_code_error">Código deve ter 6 dígitos</string> + <string name="quick_connect_success">Dispositivo autorizado com sucesso</string> + <string name="add_row">Adicionar linha</string> + <string name="genres_in">Géneros em %1$s</string> + <string name="recently_released_in">Lançado recentemente em %1$s</string> + <string name="height">Altura</string> + <string name="apply_all_rows">Aplicar a todas as linhas</string> + <string name="customize_home">Personalizar página inicial</string> + <string name="home_rows">Linhas da página inicial</string> + <string name="load_from_server">Carregar do servidor</string> + <string name="save_to_server">Gravar para o servidor</string> + <string name="load_from_web_client">Carregar a partir do cliente web</string> + <string name="use_series">Usar imagem da série</string> + <string name="add_row_for">Adicionar linha para %1$s</string> + <string name="overwrite_server_settings">Substituir as definições no servidor?</string> + <string name="overwrite_local_settings">Substituir as definições locais?</string> + <string name="for_episodes">Por episódios</string> + <string name="suggestions_for">Sugestões para %1$s</string> + <string name="increase_all_cards_size">Aumentar o tamanho de todos os cartões</string> + <string name="use_thumb_images">Usar imagens de prévia</string> + <string name="settings_saved">Configurações salvas</string> + <string name="display_presets">Mostrar pré definições</string> + <string name="display_presets_description">Predefinições embarcadas para definir todas as linhas rapidamente</string> + <string name="customize_home_summary">Escolha as linhas e imagens na página inicial</string> + <string name="decrease_all_cards_size">Diminuir o tamanho de todos os cartões</string> </resources> diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index c55a7acc..195565df 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -22,7 +22,7 @@ <string name="confirm">Potvrdiť</string> <string name="continue_watching">Pokračovať v pozeraní</string> <string name="critic_rating">Hodnotenie kritikov</string> - <string name="decimal_seconds">%.1f sekúnd</string> + <string name="decimal_seconds">%.2f sekúnd</string> <string name="default_track">Predvolené</string> <string name="delete">Vymazať</string> <string name="died">Zomrel</string> @@ -102,4 +102,211 @@ <string name="select_user">Vybrať používateľa</string> <string name="show_debug_info">Zobraziť informácie o debugu</string> <string name="compose_error_message_title">Došlo k chybe! Stlačte tlačidlo, aby ste odoslali logy na svoj server.</string> + <string name="ends_at">Končí o %1$s</string> + <string name="enter_server_address">Zadajte adresu servera</string> + <string name="no_servers_found">Nenašli sa žiadne servery</string> + <string name="only_forced_subtitles">Iba vynútené titulky</string> + <string name="voice_search">Hlasové vyhľadávanie</string> + <string name="voice_starting">Spúšťanie</string> + <string name="voice_search_prompt">Hovorením vyhľadávajte</string> + <string name="processing">Spracovanie</string> + <string name="press_back_to_cancel">Stlačte späť pre zrušenie</string> + <string name="voice_error_audio">Chyba nahrávania zvuku</string> + <string name="voice_error_client">Chyba rozpoznávania hlasu</string> + <string name="voice_error_permissions">Vyžaduje sa povolenie na prístup k mikrofónu</string> + <string name="voice_error_network">Chyba siete</string> + <string name="voice_error_network_timeout">Časový limit siete</string> + <string name="voice_error_no_match">Nebol rozpoznaný žiadny hlas</string> + <string name="voice_error_busy">Rozpoznávanie hlasu je zaneprázdnené</string> + <string name="voice_error_server">Chyba servera</string> + <string name="voice_error_speech_timeout">Nebola zistená žiadna reč</string> + <string name="voice_error_unknown">Neznáma chyba</string> + <string name="voice_error_start_failed">Nepodarilo sa spustiť rozpoznávanie hlasu</string> + <string name="voice_error_timeout">Časový limit rozpoznávania hlasu vypršal</string> + <string name="retry">Skúsiť znova</string> + <string name="show_next_up_when">Zobraziť ďalšie</string> + <string name="show">Zobraziť</string> + <string name="shuffle">Náhodne</string> + <string name="skip">Preskočiť</string> + <string name="sort_by_date_added">Dátum pridania</string> + <string name="sort_by_date_episode_added">Dátum pridania epizódy</string> + <string name="sort_by_date_played">Dátum prehratia</string> + <string name="sort_by_date_released">Dátum vydania</string> + <string name="sort_by_name">Názov</string> + <string name="sort_by_random">Náhodne</string> + <string name="studios">Štúdiá</string> + <string name="submit">Odoslať</string> + <string name="subtitle_download_too_long">Sťahovanie trvá dlho, možno budete musieť reštartovať prehrávanie</string> + <string name="subtitle">Podnázov</string> + <string name="subtitles">Titulky</string> + <string name="suggestions">Návrhy</string> + <string name="switch_servers">Zmeniť servery</string> + <string name="switch_user">Zmeniť</string> + <string name="top_unwatched">Najlepšie hodnotené Nepozreté</string> + <string name="trailer">Trailer</string> + <plurals name="trailers"> + <item quantity="one">Trailery</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="tv_dvr_schedule">DVR rozvrh</string> + <string name="tv_guide">Sprievodca</string> + <string name="tv_season">Séria</string> + <string name="tv_seasons">Série</string> + <plurals name="tv_shows"> + <item quantity="one">Seriály</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="ui_interface">Rozhranie</string> + <string name="unknown">Neznámy</string> + <string name="updates">Aktualizácie</string> + <string name="version">Verzia</string> + <string name="video_scale">Mierka videa</string> + <string name="video">Video</string> + <string name="videos">Videá</string> + <string name="watch_live">Sledovať naživo</string> + <string name="years_old">%1$d rokov</string> + <string name="play_with_transcoding">Prehrávať s prekódovaním</string> + <string name="media_information">Informácie o médiu</string> + <string name="show_clock">Zobraziť hodiny</string> + <string name="aired_episode_order">Poradie odvysielaných epizód</string> + <string name="create_playlist">Vytvoriť nový playlist</string> + <string name="add_to_playlist">Pridať do playlistu</string> + <string name="one_click_pause">Pozastavenie jedným kliknutím</string> + <string name="one_click_pause_summary_on">Stlačte stred D-Padu pre pozastavenie/prehrávanie</string> + <string name="italic_font">Kurzívový font</string> + <string name="font">Font</string> + <string name="background">Pozadie</string> + <string name="success">Úspech</string> + <string name="official_rating">Rodičovské hodnotenie</string> + <string name="runtime_sort">Trvanie</string> + <string name="extras">Bonusový materiál</string> + <plurals name="other_extras"> + <item quantity="one">Ostatné</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">Zo zákulisia</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Tématické piesne</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">Tématické videá</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="clips"> + <item quantity="one">Klipy</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="one">Vymazané scény</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="interviews"> + <item quantity="one">Rozhovory</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="scenes"> + <item quantity="one">Scény</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="samples"> + <item quantity="one">Vzorky</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="featurettes"> + <item quantity="one">Krátkometrážne filmy</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <plurals name="shorts"> + <item quantity="one">Krátke videá</item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="favorite_items">Obľúbené %s</string> + <plurals name="downloads"> + <item quantity="one">%s stiahnutý</item> + <item quantity="few">%s stiahnuté</item> + <item quantity="many">%s stiahnutých</item> + <item quantity="other">%s stiahnutých</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d hodina</item> + <item quantity="few">%d hodiny</item> + <item quantity="many">%d hodín</item> + <item quantity="other">%d hodín</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s deň</item> + <item quantity="few">%s dni</item> + <item quantity="many">%s dní</item> + <item quantity="other">%s dní</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d položka</item> + <item quantity="few">%d položky</item> + <item quantity="many">%d položiek</item> + <item quantity="other">%d položiek</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d sekunda</item> + <item quantity="few">%d sekundy</item> + <item quantity="many">%d sekúnd</item> + <item quantity="other">%d sekúnd</item> + </plurals> + <string name="ac3_supported">Zariadenie podporuje AC3/Dolby Digital</string> + <string name="advanced_settings">Pokročilé nastavenia</string> + <string name="advanced_ui">Pokročilé UI</string> + <string name="app_theme">Téma aplikácie</string> + <string name="auto_check_for_updates">Automaticky kontrolovať aktualizácie</string> + <string name="check_for_updates">Skontrolovať aktualizácie</string> + <string name="combine_continue_next_summary">Platí len pre seriály</string> + <string name="direct_play_ass">Priame prehrávanie ASS titulkov</string> + <string name="direct_play_pgs">Priame prehrávanie PGS titulkov</string> + <string name="downmix_stereo">Vždy downmixovať do stereo formátu</string> + <string name="ffmpeg_extension_pref">Použiť modul FFmpeg dekodéra</string> + <string name="global_content_scale">Predvolená mierka obsahu</string> + <string name="install_update">Nainštalovať aktualizáciu</string> + <string name="installed_version">Nainštalovaná verzia</string> + <string name="max_homepage_items">Maximálny počet položiek v riadkoch na domovskej obrazovke</string> + <string name="nav_drawer_pins_summary">Vyberte predvolené položky, ktoré sa majú zobraziť, ostatné budú skryté</string> + <string name="nav_drawer_pins">Prispôsobenie položiek navigačného panela</string> + <string name="nav_drawer_switch_on_focus_summary_off">Kliknutím prepnete stránky</string> + <string name="nav_drawer_switch_on_focus">Prepnúť stránky navigačného panelu pri zaostrení</string> + <string name="pass_out_protection">Ochrana proti výpadku</string> + <string name="play_theme_music">Prehrať tématickú hudbu</string> + <string name="remember_selected_tab">Zapamätať vybrané karty</string> + <string name="seek_bar_steps">Kroky seek baru</string> + <string name="send_app_logs_summary">Užitočné pre debugovanie</string> + <string name="send_app_logs">Odoslať logy aplikácie na aktuálny server</string> + <string name="send_crash_reports_summary">Pokus o odoslanie na posledný pripojený server</string> + <string name="send_crash_reports">Odoslať správy o zlyhaní</string> + <string name="settings">Nastavenia</string> </resources> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..aec35e54 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,475 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">Hakkında</string> + <string name="active_recordings">Etkin kayıtlar</string> + <string name="add_favorite">Favori</string> + <string name="add_server">Sunucu Ekle</string> + <string name="add_user">Kullanıcı Ekle</string> + <string name="audio">Ses</string> + <string name="birthplace">Doğum yeri</string> + <string name="bitrate">Bit hızı</string> + <string name="born">Doğum Tarihi</string> + <string name="cancel_recording">Kaydı iptal et</string> + <string name="cancel_series_recording">Dizi kaydını iptal et</string> + <string name="cancel">İptal</string> + <string name="chapters">Bölümler</string> + <string name="choose_stream">Seç: %1$s</string> + <string name="clear_image_cache">Resim önbelleğini temizle</string> + <string name="collection">Koleksiyon</string> + <string name="collections">Koleksiyonlar</string> + <string name="commercial">Reklam</string> + <string name="community_rating">Topluluk puanı</string> + <string name="compose_error_message_title">Bir hata oluştu! Günlükleri sunucunuza göndermek için düğmeye basın.</string> + <string name="confirm">Onayla</string> + <string name="continue_watching">İzlemeye devam et</string> + <string name="critic_rating">Eleştirmen puanı</string> + <string name="decimal_seconds">%.2f saniye</string> + <string name="default_track">Varsayılan</string> + <string name="delete">Sil</string> + <string name="died">Ölüm Tarihi</string> + <string name="directed_by">Yönetmen %1$s</string> + <string name="director">Yönetmen</string> + <string name="disabled">Devre dışı</string> + <string name="discovered_servers">Keşfedilen sunucular</string> + <string name="download_and_update"><![CDATA[İndir ve Güncelle]]></string> + <string name="downloading">İndiriliyor…</string> + <string name="enabled">Etkin</string> + <string name="ends_at">Bitiş saati %1$s</string> + <string name="enter_server_url">Sunucu IP adresini veya URL\'sini girin</string> + <string name="enter_server_address">Sunucu adresini girin</string> + <string name="episodes">Bölümler</string> + <string name="error_loading_collection">%1$s koleksiyonu yüklenirken bir hata oluştu</string> + <string name="external_track">Harici</string> + <string name="favorites">Favoriler</string> + <string name="file_size">Boyut</string> + <string name="forced_track">Zorunlu</string> + <string name="genres">Türler</string> + <string name="go_to_series">Dizilere git</string> + <string name="go_to">Git</string> + <string name="hide_controller_timeout">Oynatma kontrollerini gizle</string> + <string name="hide_debug_info">Hata ayıklama bilgilerini gizle</string> + <string name="hide">Gizle</string> + <string name="home">Ana Ekran</string> + <string name="immediate">Hemen</string> + <string name="intro">İntro</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="library">Kütüphane</string> + <string name="license_info">Lisans bilgileri</string> + <string name="live_tv">Canlı TV</string> + <string name="loading">Yükleniyor…</string> + <string name="mark_entire_series_as_played">Tüm diziyi izlendi olarak işaretle?</string> + <string name="mark_entire_series_as_unplayed">Tüm diziyi izlenmedi olarak işaretle?</string> + <string name="mark_unwatched">İzlenmedi olarak işaretle</string> + <string name="mark_watched">İzlendi olarak işaretle</string> + <string name="max_bitrate">Maksimum bit hızı</string> + <string name="more_like_this">Benzerleri</string> + <string name="more">Diğer</string> + <plurals name="movies"> + <item quantity="one">Film</item> + <item quantity="other">Filmler</item> + </plurals> + <string name="name">Ad</string> + <string name="next_up">Sıradaki bölümler</string> + <string name="no_data">Veri yok</string> + <string name="no_results">Sonuç bulunamadı</string> + <string name="no_servers_found">Sunucu bulunamadı</string> + <string name="no_scheduled_recordings">Planlanmış kayıt yok</string> + <string name="no_update_available">Güncelleme mevcut değil</string> + <string name="none">Hiçbiri</string> + <string name="only_forced_subtitles">Sadece zorunlu altyazılar</string> + <string name="outro">Kapanış jeneriği</string> + <string name="path">Yol</string> + <plurals name="people"> + <item quantity="one">Kişi</item> + <item quantity="other">Kişiler</item> + </plurals> + <string name="play_count">Oynatma Sayısı</string> + <string name="play_from_here">Buradan oynat</string> + <string name="play">Oynat</string> + <string name="playback_debug_info">Oynatma hata ayıklama bilgilerini göster</string> + <string name="playback_speed">Oynatma hızı</string> + <string name="playback">Oynatma</string> + <string name="playlist">Oynatma Listesi</string> + <string name="playlists">Oynatma Listeleri</string> + <string name="preview">Önizleme</string> + <string name="profile_specific_settings">Kullanıcı Profili Ayarları</string> + <string name="queue">Kuyruk</string> + <string name="recap">Özet</string> + <string name="recently_added_in">%1$s kütüphanesine son eklenenler</string> + <string name="recently_added">Son eklenen</string> + <string name="recently_recorded">Son Kaydedilenler</string> + <string name="recently_released">Son Çıkanlar</string> + <string name="recommended">Önerilenler</string> + <string name="record_program">Programı Kaydet</string> + <string name="record_series">Diziyi Kaydet</string> + <string name="remove_favorite">Favorilerden kaldır</string> + <string name="restart">Yeniden Başlat</string> + <string name="resume">Sürdür</string> + <string name="save">Kaydet</string> + <string name="search_and_download"><![CDATA[Ara ve İndir]]></string> + <string name="search">Ara</string> + <string name="searching">Aranıyor…</string> + <string name="voice_search">Sesli arama</string> + <string name="voice_starting">Başlatılıyor</string> + <string name="voice_search_prompt">Aramak için konuşun</string> + <string name="processing">İşleniyor</string> + <string name="press_back_to_cancel">İptal etmek için geri tuşuna basın</string> + <string name="voice_error_audio">Ses kayıt hatası</string> + <string name="voice_error_client">Ses tanıma hatası</string> + <string name="voice_error_permissions">Mikrofon izni gerekiyor</string> + <string name="voice_error_network">Ağ hatası</string> + <string name="voice_error_network_timeout">Ağ zaman aşımı</string> + <string name="voice_error_no_match">Ses tanınmadı</string> + <string name="voice_error_busy">Ses tanıma meşgul</string> + <string name="voice_error_server">Sunucu hatası</string> + <string name="voice_error_speech_timeout">Ses algılanamadı</string> + <string name="voice_error_unknown">Bilinmeyen hata</string> + <string name="voice_error_start_failed">Ses tanıma başlatılamadı</string> + <string name="voice_error_timeout">Ses tanıma zaman aşımına uğradı</string> + <string name="retry">Yeniden deneyin</string> + <string name="select_server">Sunucu Seç</string> + <string name="select_user">Kullanıcı Seç</string> + <string name="show_debug_info">Hata ayıklama bilgisini göster</string> + <string name="show_next_up_when">Sıradakini göster</string> + <string name="show">Göster</string> + <string name="shuffle">Karıştır</string> + <string name="skip">Atla</string> + <string name="sort_by_date_added">Eklenme tarihi</string> + <string name="sort_by_date_episode_added">Bölüm Eklenme Tarihi</string> + <string name="sort_by_date_played">Oynatma Tarihi</string> + <string name="sort_by_date_released">Yayınlanma Tarihi</string> + <string name="sort_by_name">Ad</string> + <string name="sort_by_random">Rastgele</string> + <string name="studios">Yapım Stüdyoları</string> + <string name="submit">Gönder</string> + <string name="subtitle_download_too_long">İndirme işlemi uzun sürüyor, oynatmayı yeniden başlatmanız gerekebilir</string> + <string name="subtitle">Altyazı</string> + <string name="subtitles">Altyazılar</string> + <string name="suggestions">Öneriler</string> + <string name="switch_servers">Sunucu değiştir</string> + <string name="switch_user">Değiştir</string> + <string name="top_unwatched">İzlenmemiş En İyiler</string> + <string name="trailer">Fragman</string> + <plurals name="trailers"> + <item quantity="one">Fragman</item> + <item quantity="other">Fragmanlar</item> + </plurals> + <string name="tv_dvr_schedule">Kayıt Takvimi</string> + <string name="tv_guide">Rehber</string> + <string name="tv_season">Sezon</string> + <string name="tv_seasons">Sezonlar</string> + <plurals name="tv_shows"> + <item quantity="one">Dizi</item> + <item quantity="other">Diziler</item> + </plurals> + <string name="ui_interface">Arayüz</string> + <string name="unknown">Bilinmiyor</string> + <string name="updates">Güncellemeler</string> + <string name="version">Sürüm</string> + <string name="video_scale">Video Ölçeği</string> + <string name="video">Video</string> + <string name="videos">Videolar</string> + <string name="watch_live">Canlı izle</string> + <string name="years_old">%1$d yaşında</string> + <string name="play_with_transcoding">Kod dönüştürerek oynat</string> + <string name="media_information">Medya Bilgisi</string> + <string name="show_clock">Saati göster</string> + <string name="aired_episode_order">Yayınlanma Sırası</string> + <string name="create_playlist">Yeni oynatma listesi oluştur</string> + <string name="add_to_playlist">Oynatma listesine ekle</string> + <string name="one_click_pause">Tek tıkla duraklat</string> + <string name="one_click_pause_summary_on">Durdur/Oynat için orta tuşa basın</string> + <string name="italic_font">Metni italik yap</string> + <string name="font">Font</string> + <string name="background">Arka plan</string> + <string name="success">Başarılı</string> + <string name="official_rating">Yaş Derecelendirmesi</string> + <string name="runtime_sort">Süre</string> + <string name="extras">Ekstralar</string> + <plurals name="other_extras"> + <item quantity="one">Diğer Ekstra</item> + <item quantity="other">Diğer Ekstralar</item> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">Sahne Arkası</item> + <item quantity="other">Sahne Arkaları</item> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Tema şarkısı</item> + <item quantity="other">Tema şarkıları</item> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">Tema Videosu</item> + <item quantity="other">Tema Videoları</item> + </plurals> + <plurals name="clips"> + <item quantity="one">Klip</item> + <item quantity="other">Klipler</item> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="one">Silinmiş Sahne</item> + <item quantity="other">Silinmiş Sahneler</item> + </plurals> + <plurals name="interviews"> + <item quantity="one">Röportaj</item> + <item quantity="other">Röportajlar</item> + </plurals> + <plurals name="scenes"> + <item quantity="one">Sahne</item> + <item quantity="other">Sahneler</item> + </plurals> + <plurals name="samples"> + <item quantity="one">Örnek</item> + <item quantity="other">Örnekler</item> + </plurals> + <plurals name="featurettes"> + <item quantity="one">Tanıtım</item> + <item quantity="other">Tanıtımlar</item> + </plurals> + <plurals name="shorts"> + <item quantity="one">Kısa Video</item> + <item quantity="other">Kısa Videolar</item> + </plurals> + <plurals name="downloads"> + <item quantity="one">%s indirme</item> + <item quantity="other">%s indirilenler</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d saat</item> + <item quantity="other">%d saat</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d öğe</item> + <item quantity="other">%d öğeler</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d saniye</item> + <item quantity="other">%d saniye</item> + </plurals> + <string name="ac3_supported">AC3/Dolby Digital desteği</string> + <string name="advanced_settings">Gelişmiş Ayarlar</string> + <string name="advanced_ui">Gelişmiş Arayüz</string> + <string name="app_theme">Uygulama teması</string> + <string name="auto_check_for_updates">Güncellemeleri otomatik denetle</string> + <string name="auto_play_next_delay">Sıradakini oynatma gecikmesi</string> + <string name="auto_play_next">Sıradakini otomatik oynat</string> + <string name="check_for_updates">Güncellemeleri kontrol et</string> + <string name="combine_continue_next_summary">Yalnızca diziler için geçerlidir</string> + <string name="combine_continue_next"><![CDATA[İzlemeye Devam Et ve Sıradaki Bölümleri Birleştir]]></string> + <string name="direct_play_ass">ASS altyazıları doğrudan oynat</string> + <string name="direct_play_pgs">PGS altyazıları doğrudan oynat</string> + <string name="downmix_stereo">Her zaman Stereo\'ya dönüştür</string> + <string name="ffmpeg_extension_pref">FFmpeg kod çözücü kullan</string> + <string name="global_content_scale">Varsayılan içerik ölçeği</string> + <string name="install_update">Güncellemeyi yükle</string> + <string name="installed_version">Yüklü sürüm</string> + <string name="max_homepage_items">Ana sayfa satırlarında maksimum öge sayısı</string> + <string name="nav_drawer_pins_summary">Gösterilecek varsayılan ögeleri seçin, diğerleri gizlenecektir</string> + <string name="nav_drawer_pins">Yan Menü Ögelerini Düzenle</string> + <string name="nav_drawer_switch_on_focus_summary_off">Sayfalar arası geçiş için tıklayın</string> + <string name="nav_drawer_switch_on_focus">Odaklanınca yan menü sayfalarını değiştir</string> + <string name="pass_out_protection">Uyuyakalma Koruması</string> + <string name="play_theme_music">Tema müziğini oynat</string> + <string name="playback_overrides">Oynatmayı geçersiz kılanlar</string> + <string name="remember_selected_tab">Seçili sekmeleri hatırla</string> + <string name="rewatch_next_up">Sıradaki bölümlerde tekrar izlemeyi etkinleştir</string> + <string name="seek_bar_steps">İlerleme çubuğu adımları</string> + <string name="send_app_logs_summary">Hata ayıklama için yararlıdır</string> + <string name="send_app_logs">Uygulama günlüklerini mevcut sunucuya gönder</string> + <string name="send_crash_reports_summary">Son bağlanan sunucuya gönderilmeye çalışılacak</string> + <string name="send_crash_reports">Hata raporlarını gönder</string> + <string name="settings">Ayarlar</string> + <string name="skip_back_on_resume_preference">Devam ederken videoyu geri sar</string> + <string name="skip_back_preference">Geri atla</string> + <string name="skip_commercials_behavior">Reklam geçme davranışı</string> + <string name="skip_forward_preference">İleri atla</string> + <string name="skip_intro_behavior">İntro atlama davranışı</string> + <string name="skip_outro_behavior">Kapanış jeneriği atlama davranışı</string> + <string name="skip_previews_behavior">Önizlemeyi atlama davranışı</string> + <string name="skip_recap_behavior">Özet atlama davranışı</string> + <string name="update_available">Güncelleme mevcut</string> + <string name="update_url_summary">Uygulama güncellemelerini kontrol etmek için kullanılan URL</string> + <string name="update_url">Güncelleme URL\'si</string> + <string name="font_size">Yazı tipi boyutu</string> + <string name="font_color">Yazı tipi rengi</string> + <string name="font_opacity">Yazı tipi opaklığı</string> + <string name="edge_style">Kenar stili</string> + <string name="edge_color">Kenar rengi</string> + <string name="background_opacity">Arka plan opaklığı</string> + <string name="background_style">Arka plan stili</string> + <string name="subtitle_style">Altyazı stili</string> + <string name="background_color">Arka plan rengi</string> + <string name="reset">Sıfırla</string> + <string name="bold_font">Kalın yazı tipi</string> + <string name="player_backend">Oynatıcı motoru</string> + <string name="mpv_hardware_decoding">MPV: Donanım kod çözmeyi kullan</string> + <string name="disable_if_crash">Çökme sorunu yaşıyorsanız devre dışı bırakın</string> + <string name="mpv_options">MPV Ayarları</string> + <string name="exoplayer_options">ExoPlayer Ayarları</string> + <string name="skip_segment_unknown">Bölümü atla</string> + <string name="skip_segment_commercial">Reklamları atla</string> + <string name="skip_segment_preview">Önizlemeyi atla</string> + <string name="skip_segment_recap">Özeti atla</string> + <string name="skip_segment_outro">Kapanış jeneriğini atla</string> + <string name="skip_segment_intro">İntroyu atla</string> + <string name="played">Oynatıldı</string> + <string name="filter">Filtre</string> + <string name="year">Yıl</string> + <string name="decade">Dönem</string> + <string name="remove">Sil</string> + <string name="mpv_use_gpu_next">MPV: gpu-next kullan</string> + <string name="mpv_conf">mpv.conf dosyasını düzenle</string> + <string name="discard_change">Değişiklikleri iptal et?</string> + <string name="subtitle_margin">Kenar boşluğu</string> + <string name="verbose_logging">Ayrıntılı günlükleme</string> + <string name="enter_pin">PIN girin</string> + <string name="sign_in_auto">Otomatik oturum aç</string> + <string name="use_server_credentials">Sunucu üzerinden giriş yap</string> + <string name="press_enter_to_confirm">Onaylamak için orta tuşa basın</string> + <string name="require_pin_code">Profil için PIN iste</string> + <string name="confirm_pin">PIN\'i onayla</string> + <string name="incorrect">Hatalı</string> + <string name="pin_too_short">PIN en az 4 haneli olmalıdır</string> + <string name="will_remove_pin">PIN kaldırılacak</string> + <string name="image_cache_size">Görüntü disk önbellek boyutu (MB)</string> + <string name="view_options">Görünüm seçenekleri</string> + <string name="columns">Sütun sayısı</string> + <string name="spacing">Öğe aralığı</string> + <string name="aspect_ratio">En Boy Oranı</string> + <string name="show_details">Detayları göster</string> + <string name="image_type">Görsel türü</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="cast_and_crew"><![CDATA[Oyuncular ve Ekip]]></string> + <string name="guest_stars">Konuk oyuncular</string> + <string name="edge_size">Kenar boyutu</string> + <string name="refresh_rate_switching">Yenileme hızı değiştirme</string> + <string name="automatic">Otomatik</string> + <string name="username_or_password">Kullanıcı adı/şifre kullan</string> + <string name="login">Oturum aç</string> + <string name="general">Genel</string> + <string name="container">Kapsayıcı</string> + <string name="title">Başlık</string> + <string name="codec">Codec</string> + <string name="profile">Profil</string> + <string name="level">Düzey</string> + <string name="resolution">Çözünürlük</string> + <string name="anamorphic">Anamorfik</string> + <string name="interlaced">Geçmeli</string> + <string name="framerate">Kare hızı</string> + <string name="bit_depth">Bit derinliği</string> + <string name="video_range">Video aralığı</string> + <string name="video_range_type">Video aralık türü</string> + <string name="color_space">Renk alanı</string> + <string name="color_transfer">Renk aktarımı</string> + <string name="color_primaries">Renk ön seçimleri</string> + <string name="pixel_format">Piksel biçimi</string> + <string name="ref_frames">Referans kareler</string> + <string name="nal">NAL</string> + <string name="language">Dil</string> + <string name="layout">Düzen</string> + <string name="channels">Kanallar</string> + <string name="sample_rate">Örnekleme hızı</string> + <string name="avc">AVC</string> + <string name="yes">Evet</string> + <string name="no">Hayır</string> + <string name="sdr">SDR</string> + <string name="hdr">HDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="bit_unit">bit</string> + <string name="sample_rate_unit">Hz</string> + <string name="show_titles">Başlıkları göster</string> + <string name="live_tv_repeat">Tekrarla</string> + <string name="favorite_channels_at_beginning">Favori kanalları en başta göster</string> + <string name="sort_channels_recently_watched">Kanalları son izlenene göre sırala</string> + <string name="color_code_programs">Programları türüne göre renklendir</string> + <string name="subtitle_delay">Altyazı gecikmesi</string> + <string name="backdrop_display">Görsel arka plan stili</string> + <string name="resolution_switching">Çözünürlük değiştirme</string> + <string name="local">Lokal</string> + <string name="play_trailer">Fragmanı oynat</string> + <string name="no_trailers">Fragman yok</string> + <string name="clear_track_choices">Parça seçimlerini temizle</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="truehd">TrueHD</string> + <string name="dolby_digital">DD</string> + <string name="dolby_digital_plus">DD+</string> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Dolby Vision Profil 7\'yi doğrudan oynat</string> + <string name="force_dovi_profile_7_summary">Cihaz uyumluluk kontrollerini yoksay</string> + <string name="discover">Keşfet</string> + <string name="request">İstek</string> + <string name="pending">Beklemede</string> + <string name="seerr_integration">Seerr entegrasyonu</string> + <string name="remove_seerr_server">Seerr Sunucusunu Kaldır</string> + <string name="seerr_server_added">Seerr sunucusu eklendi</string> + <string name="password">Şifre</string> + <string name="username">Kullanıcı adı</string> + <string name="url">URL</string> + <string name="trending">Popüler</string> + <string name="upcoming_movies">Yaklaşan Filmler</string> + <string name="upcoming_tv">Yaklaşan Diziler</string> + <string name="request_4k">4K olarak iste</string> + <string name="software_decoding_av1">AV1 yazılımsal kod çözme</string> + <string name="upgrade_mpv_toast">HDR içerikler hariç varsayılan oynatıcı artık MPV. \nBu ayarı ayarlar bölümünden değiştirebilirsiniz.</string> + <string name="hdr_subtitle_style">HDR Altyazı Stili</string> + <string name="image_subtitle_opacity">Altyazı Arka Plan Matlığı</string> + <string name="brightness">Parlaklık</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Doygunluk</string> + <string name="hue">Ton</string> + <string name="red">Kırmızı</string> + <string name="green">Yeşil</string> + <string name="blue">Mavi</string> + <string name="blur">Bulanıklık</string> + <string name="save_for_album">Albüme Kaydet</string> + <string name="play_slideshow">Slayt Gösterisini Oynat</string> + <string name="stop_slideshow">Slayt Gösterisini Durdur</string> + <string name="slideshow_at_beginning">En baştan</string> + <string name="no_more_images">Daha fazla fotoğraf yok</string> + <string name="rotate_left">Sola döndür</string> + <string name="rotate_right">Sağa döndür</string> + <string name="zoom_in">Yakınlaştır</string> + <string name="zoom_out">Uzaklaştır</string> + <string name="slideshow_duration">Slayt Gösterisi Süresi</string> + <string name="play_videos_during_slideshow">Slayt gösterisi sırasında videoları oynat</string> + <plurals name="days"> + <item quantity="one">%s gün</item> + <item quantity="other">%s günler</item> + </plurals> + <string name="send_media_info_log_to_server">Medya bilgisini sunucuya gönder</string> + <string name="no_limit">Sınırsız</string> + <string name="max_days_next_up">Sıradaki bölümlerde kalacağı gün sayısı</string> + <string name="quick_connect">Hızlı Bağlan</string> + <string name="quick_connect_summary">Başka bir cihazın giriş yapmasına izin ver</string> + <string name="quick_connect_code">Hızlı Bağlan kodunu girin</string> + <string name="quick_connect_code_error">Kod 6 haneli olmalıdır</string> + <string name="quick_connect_success">Cihaz başarıyla yetkilendirildi</string> + <string name="add_row">Satır ekle</string> + <string name="genres_in">%1$s içindeki türler</string> + <string name="recently_released_in">%1$s kütüphanesinde son çıkanlar</string> + <string name="height">Yükseklik</string> + <string name="apply_all_rows">Tüm satırlara uygula</string> + <string name="customize_home">Ana ekranı özelleştir</string> + <string name="home_rows">Ana ekran satırları</string> + <string name="load_from_server">Kullanıcı profilinden yükle</string> + <string name="save_to_server">Kullanıcı profiline kaydet</string> + <string name="load_from_web_client">Web istemcisinden yükle</string> + <string name="use_series">Dizi görselini kullan</string> + <string name="add_row_for">%1$s için satır ekle</string> + <string name="overwrite_server_settings">Sunucudaki ayarların üzerine yazılsın mı?</string> + <string name="overwrite_local_settings">Yerel ayarların üzerine yazılsın mı?</string> + <string name="for_episodes">Bölümler için</string> + <string name="suggestions_for">%1$s için tavsiyeler</string> + <string name="increase_all_cards_size">Tüm kartların boyutunu artır</string> + <string name="decrease_all_cards_size">Tüm kartların boyutunu azalt</string> + <string name="use_thumb_images">Küçük resimleri kullan</string> + <string name="settings_saved">Ayarlar kaydedildi</string> + <string name="display_presets">Görüntü ön ayarları</string> + <string name="display_presets_description">Tüm satırları hızlıca stilize etmek için hazır ayarlar</string> + <string name="favorite_items">Favori %s</string> + <string name="customize_home_summary">Ana sayfadaki satırları ve görselleri seçin</string> +</resources> diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index dce8332a..1a31690d 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -23,7 +23,7 @@ <string name="confirm">Підтвердити</string> <string name="continue_watching">Продовжити перегляд</string> <string name="critic_rating">Оцінка критиків</string> - <string name="decimal_seconds">%.1fсекунд</string> + <string name="decimal_seconds">%.1f секунд</string> <string name="default_track">За замовчуванням</string> <string name="delete">Видалити</string> <string name="died">Помер</string> @@ -453,4 +453,63 @@ <string name="request_4k">Запит в 4К</string> <string name="software_decoding_av1">Програмне декодування AV1</string> <string name="upgrade_mpv_toast">MPV тепер є плеєром за замовчуванням, за винятком для HDR.\nВи можете змінити це в налаштуваннях.</string> + <string name="hdr_subtitle_style">HDR субтитри</string> + <string name="image_subtitle_opacity">Прозорість зображення заголовку</string> + <string name="brightness">Яскравість</string> + <string name="contrast">Контрастність</string> + <string name="saturation">Насичення</string> + <string name="hue">Відтінок</string> + <string name="red">Червоний</string> + <string name="green">Зелений</string> + <string name="blue">Синій</string> + <string name="blur">Розмиття</string> + <string name="save_for_album">Зберегти до альбому</string> + <string name="play_slideshow">Відтворити слайд-шоу</string> + <string name="stop_slideshow">Зупинити слайд-шоу</string> + <string name="slideshow_at_beginning">На початок</string> + <string name="no_more_images">Фото більше немає</string> + <string name="rotate_left">Повернути ліворуч</string> + <string name="rotate_right">Повернути праворуч</string> + <string name="zoom_in">Збільшити</string> + <string name="zoom_out">Зменшити</string> + <string name="slideshow_duration">Тривалість слайд-шоу</string> + <string name="play_videos_during_slideshow">Відтворення відео під час слайд-шоу</string> + <string name="favorite_items">Улюблені %s</string> + <plurals name="days"> + <item quantity="one">день</item> + <item quantity="few">дні</item> + <item quantity="many">днів</item> + <item quantity="other">днів</item> + </plurals> + <string name="quick_connect">Швидке підключення</string> + <string name="quick_connect_summary">Дозвольте іншому пристрою увійти у ваш обліковий запис</string> + <string name="quick_connect_code">Введіть код швидкого підключення</string> + <string name="quick_connect_code_error">Код повинен складатися з 6 цифр</string> + <string name="quick_connect_success">Пристрій успішно авторизовано</string> + <string name="send_media_info_log_to_server">Надіслати інформацію про медіа на сервер</string> + <string name="no_limit">Без обмежень</string> + <string name="max_days_next_up">Максимальна кількість днів в Очікуються</string> + <string name="add_row">Додати рядок</string> + <string name="genres_in">Жанри %1$s</string> + <string name="recently_released_in">Нещодавно додані %1$s</string> + <string name="height">Висота</string> + <string name="apply_all_rows">Застосувати для всіх рядків</string> + <string name="customize_home">Змінити головну сторінку</string> + <string name="home_rows">Рядки на головній сторінці</string> + <string name="load_from_server">Завантажувати профіль користувача з сереверу</string> + <string name="save_to_server">Зберегти профіль користувача на сервер</string> + <string name="load_from_web_client">Завантажити з веб клієнта</string> + <string name="use_series">Використовувати зображення серіалу</string> + <string name="add_row_for">Додати рядок для %1$s</string> + <string name="overwrite_server_settings">Перезаписати налаштування на сервері?</string> + <string name="overwrite_local_settings">Перезаписати налаштування на цьому пристрої?</string> + <string name="for_episodes">Для епізодів</string> + <string name="suggestions_for">Рекомендації %1$s</string> + <string name="increase_all_cards_size">Збільшити розмір карток</string> + <string name="decrease_all_cards_size">Зменшити розмір карток</string> + <string name="use_thumb_images">Використовувати мініатюру</string> + <string name="settings_saved">Налаштування збережені</string> + <string name="display_presets">Налаштування дисплея</string> + <string name="display_presets_description">Вбудовані налаштування для швидкого оформлення всіх рядків</string> + <string name="customize_home_summary">Обери рядки і зображення для головної сторінки</string> </resources> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 10a25eac..28875ea9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -23,7 +23,7 @@ <string name="confirm">确认</string> <string name="continue_watching">继续观看</string> <string name="critic_rating">影评人评分</string> - <string name="decimal_seconds">%.1f 秒</string> + <string name="decimal_seconds">%.2f 秒</string> <string name="default_track">默认</string> <string name="delete">删除</string> <string name="died">去世</string> @@ -117,7 +117,7 @@ <string name="subtitle_download_too_long">下载时间过长,可能需要重新开始播放</string> <string name="subtitle">字幕</string> <string name="subtitles">字幕</string> - <string name="suggestions">建议</string> + <string name="suggestions">推荐</string> <string name="switch_servers">切换服务器</string> <string name="switch_user">切换</string> <string name="top_unwatched">高分未看</string> @@ -215,7 +215,7 @@ <string name="global_content_scale">默认内容比例</string> <string name="install_update">安装更新</string> <string name="installed_version">安装版本</string> - <string name="max_homepage_items">首页每行最大项目数</string> + <string name="max_homepage_items">首页行最大项目数</string> <string name="nav_drawer_pins_summary">选择默认显示的项目,其他项目将隐藏</string> <string name="nav_drawer_pins">自定义抽屉式导航栏项目</string> <string name="nav_drawer_switch_on_focus_summary_off">点击切换页面</string> @@ -231,10 +231,10 @@ <string name="send_crash_reports_summary">将尝试发送到最后连接的服务器</string> <string name="send_crash_reports">发送崩溃报告</string> <string name="settings">设置</string> - <string name="skip_back_on_resume_preference">恢复播放时快退</string> - <string name="skip_back_preference">快退</string> + <string name="skip_back_on_resume_preference">恢复播放时自动向后跳过</string> + <string name="skip_back_preference">向后跳过</string> <string name="skip_commercials_behavior">跳过广告方式</string> - <string name="skip_forward_preference">快进</string> + <string name="skip_forward_preference">向前跳过</string> <string name="skip_intro_behavior">跳过片头方式</string> <string name="skip_outro_behavior">跳过片尾方式</string> <string name="skip_previews_behavior">跳过预览方式</string> @@ -390,4 +390,60 @@ <string name="retry">重试</string> <string name="software_decoding_av1">AV1 软件解码</string> <string name="upgrade_mpv_toast">除 HDR 视频外,mpv 现在是默认播放器。\n您可以在设置中更改此设置。</string> + <string name="hdr_subtitle_style">HDR 字幕样式</string> + <string name="image_subtitle_opacity">图像字幕不透明度</string> + <string name="brightness">亮度</string> + <string name="contrast">对比度</string> + <string name="saturation">饱和度</string> + <string name="hue">色调</string> + <string name="red">红色</string> + <string name="green">绿色</string> + <string name="blue">蓝色</string> + <string name="blur">模糊</string> + <string name="save_for_album">保存到相册</string> + <string name="play_slideshow">播放幻灯片</string> + <string name="stop_slideshow">停止幻灯片</string> + <string name="slideshow_at_beginning">回到开头</string> + <string name="no_more_images">没有更多照片</string> + <string name="rotate_left">向左旋转</string> + <string name="rotate_right">向右旋转</string> + <string name="zoom_in">放大</string> + <string name="zoom_out">缩小</string> + <string name="slideshow_duration">幻灯片时长</string> + <string name="play_videos_during_slideshow">幻灯片播放期间播放视频</string> + <string name="send_media_info_log_to_server">将媒体信息日志发送到服务器</string> + <plurals name="days"> + <item quantity="other">%s 天</item> + </plurals> + <string name="no_limit">无限制</string> + <string name="max_days_next_up">即将播放中的最大天数</string> + <string name="quick_connect">快速连接</string> + <string name="quick_connect_summary">授权其他设备登录您的账号</string> + <string name="quick_connect_code">输入快速连接代码</string> + <string name="quick_connect_code_error">代码必须为六位数字</string> + <string name="quick_connect_success">设备授权成功</string> + <string name="add_row">添加行</string> + <string name="genres_in">%1$s 中的类型</string> + <string name="recently_released_in">%1$s 最近发行</string> + <string name="height">高度</string> + <string name="apply_all_rows">应用到所有行</string> + <string name="customize_home">自定义首页</string> + <string name="home_rows">首页行</string> + <string name="load_from_server">从服务器用户配置加载</string> + <string name="save_to_server">保存到服务器用户配置</string> + <string name="load_from_web_client">从网络客户端加载</string> + <string name="use_series">使用剧集图片</string> + <string name="add_row_for">为 %1$s 添加行</string> + <string name="overwrite_server_settings">覆盖服务器上的设置?</string> + <string name="overwrite_local_settings">覆盖本地设置?</string> + <string name="for_episodes">针对剧集</string> + <string name="suggestions_for">%1$s 推荐内容</string> + <string name="increase_all_cards_size">增大所有卡片的尺寸</string> + <string name="decrease_all_cards_size">减小所有卡片的尺寸</string> + <string name="use_thumb_images">使用缩略图</string> + <string name="settings_saved">设置已保存</string> + <string name="display_presets">显示预设</string> + <string name="display_presets_description">可快速设置所有行样式的内置预设</string> + <string name="customize_home_summary">选择首页上的行和图片</string> + <string name="favorite_items">收藏的 %s</string> </resources> diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index fd54746c..6670d10e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -119,10 +119,10 @@ <string name="update_url">更新的 URL</string> <string name="font_size">字體大小</string> <string name="font_color">字體顏色</string> - <string name="font_opacity">字體透明度</string> + <string name="font_opacity">字體不透明度</string> <string name="edge_style">邊框樣式</string> <string name="edge_color">邊框顏色</string> - <string name="background_opacity">背景透明度</string> + <string name="background_opacity">背景不透明度</string> <string name="background_style">背景樣式</string> <string name="subtitle_style">字幕樣式</string> <string name="background_color">背景顏色</string> @@ -160,7 +160,7 @@ <string name="confirm">確認</string> <string name="continue_watching">繼續觀看</string> <string name="critic_rating">影評人評分</string> - <string name="decimal_seconds">%.1f 秒</string> + <string name="decimal_seconds">%.2f 秒</string> <string name="default_track">預設</string> <string name="delete">刪除</string> <string name="died">去世</string> @@ -389,4 +389,93 @@ <string name="voice_error_timeout">語音辨識逾時</string> <string name="retry">重試</string> <string name="software_decoding_av1">AV1 軟體解碼</string> + <string name="upgrade_mpv_toast">除了 HDR 影片外,MPV 已成為預設播放器。 \n您可以在設定中變更此設定。</string> + <string name="hdr_subtitle_style">HDR 字幕樣式</string> + <string name="contrast">對比</string> + <string name="saturation">飽和度</string> + <string name="hue">色調</string> + <string name="red">紅色</string> + <string name="green">綠色</string> + <string name="blue">藍色</string> + <string name="blur">模糊</string> + <string name="save_for_album">存為相簿</string> + <string name="play_slideshow">播放幻燈片</string> + <string name="stop_slideshow">停播幻燈片</string> + <string name="slideshow_at_beginning">從頭開始</string> + <string name="no_more_images">已無更多照片</string> + <string name="rotate_left">向左旋轉</string> + <string name="rotate_right">向右旋轉</string> + <string name="zoom_in">放大</string> + <string name="zoom_out">縮小</string> + <string name="slideshow_duration">幻燈片播放時間</string> + <string name="play_videos_during_slideshow">幻燈片播放時播放影片</string> + <string name="image_subtitle_opacity">圖形字幕不透明度</string> + <string name="brightness">亮度</string> + <plurals name="days"> + <item quantity="other">%s 天</item> + </plurals> + <string name="send_media_info_log_to_server">將媒體訊息日誌傳送到伺服器</string> + <string name="no_limit">無限制</string> + <string name="max_days_next_up">接下來播放的保留天數</string> + <string name="quick_connect">快速連線</string> + <string name="quick_connect_summary">授權其他裝置登入您的帳號</string> + <string name="quick_connect_code">輸入快速連線驗證碼</string> + <string name="quick_connect_code_error">驗證碼必須為6位數</string> + <string name="quick_connect_success">裝置授權成功</string> + <string name="genres_in">%1$s 類型</string> + <string name="recently_released_in">最近發行於 %1$s</string> + <string name="suggestions_for">推薦的 %1$s</string> + <string name="increase_all_cards_size">放大所有海報尺寸</string> + <string name="decrease_all_cards_size">縮小所有海報尺寸</string> + <string name="use_thumb_images">使用橫向縮圖</string> + <string name="add_row">新增一列</string> + <string name="apply_all_rows">套用到所有列</string> + <string name="home_rows">首頁各列</string> + <string name="add_row_for">為 %1$s 新增列項目</string> + <string name="display_presets_description">快速設定所有項目為內建預設樣式</string> + <string name="customize_home_summary">選擇首頁要顯示的列項目及圖片樣式</string> + <string name="height">高度</string> + <string name="customize_home">自訂首頁</string> + <string name="load_from_server">從伺服器載入設定</string> + <string name="save_to_server">保存設定到伺服器</string> + <string name="load_from_web_client">從網頁版載入</string> + <string name="use_series">使用劇集圖片</string> + <string name="overwrite_server_settings">覆蓋伺服器上的設定?</string> + <string name="overwrite_local_settings">覆蓋本地設定?</string> + <string name="settings_saved">設定已儲存</string> + <string name="display_presets">顯示預設樣式</string> + <string name="for_episodes">針對單集</string> + <string name="content_scale_fit">原比例縮放</string> + <string name="content_scale_crop">裁切填滿</string> + <string name="content_scale_fill">拉伸填滿</string> + <string name="content_scale_fill_width">填滿寬度</string> + <string name="content_scale_fill_height">填滿高度</string> + <string name="volume_lowest">最低</string> + <string name="volume_low">低</string> + <string name="volume_medium">中等</string> + <string name="volume_high">高</string> + <string name="volume_full">最大</string> + <string name="purple">紫色</string> + <string name="orange">橘色</string> + <string name="bold_blue">深藍色</string> + <string name="black">黑色</string> + <string name="skip_ignore">不處理</string> + <string name="skip_automatically">自動跳過</string> + <string name="skip_ask">詢問是否跳過</string> + <string name="ffmpeg_fallback">僅在無內建解碼器時使用 FFmpeg</string> + <string name="ffmpeg_prefer">優先使用 FFmpeg 而非內建解碼器</string> + <string name="ffmpeg_never">不使用 FFmpeg 解碼器</string> + <string name="next_up_playback_end">在播放結束時</string> + <string name="next_up_outro">在播到片尾時</string> + <string name="white">白色</string> + <string name="light_gray">淺灰色</string> + <string name="dark_gray">深灰色</string> + <string name="yellow">黃色</string> + <string name="cyan">青色</string> + <string name="magenta">洋紅色</string> + <string name="subtitle_edge_outline">外框</string> + <string name="subtitle_edge_shadow">陰影</string> + <string name="background_style_wrap">貼合文字</string> + <string name="background_style_boxed">長方底框</string> + <string name="prefer_mpv">優先使用 MPV</string> </resources> diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index ae2a4ae1..38cf32c1 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -9,6 +9,7 @@ <string name="fa_images" translatable="false"></string> <string name="fa_rotate_right" translatable="false"></string> <string name="fa_rotate_left" translatable="false"></string> + <string name="fa_arrows_rotate" translatable="false"></string> <string name="fa_arrow_right_arrow_left" translatable="false"></string> <string name="fa_magnifying_glass_plus" translatable="false"></string> <string name="fa_magnifying_glass_minus" translatable="false"></string> @@ -50,4 +51,6 @@ <string name="fa_clock" translatable="false"></string> <string name="fa_bell" translatable="false"></string> <string name="fa_check" translatable="false"></string> + <string name="fa_cloud_arrow_up" translatable="false"></string> + <string name="fa_cloud_arrow_down" translatable="false"></string> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c83a3b63..c7a2fb92 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,7 +25,7 @@ <string name="confirm">Confirm</string> <string name="continue_watching">Continue watching</string> <string name="critic_rating">Critic Rating</string> - <string name="decimal_seconds">%.1f seconds</string> + <string name="decimal_seconds">%.2f seconds</string> <string name="default_track">Default</string> <string name="delete">Delete</string> <string name="died">Died</string> @@ -188,6 +188,7 @@ <string name="samples">Samples</string> <string name="featurettes">Featurettes</string> <string name="shorts">Shorts</string> + <string name="favorite_items">Favorite %s</string> <plurals name="trailers"> <item quantity="zero">Trailers</item> @@ -260,6 +261,11 @@ <item quantity="one">%d hour</item> <item quantity="other">%d hours</item> </plurals> + <plurals name="days"> + <item quantity="zero">%s days</item> + <item quantity="one">%s day</item> + <item quantity="other">%s days</item> + </plurals> <plurals name="items"> <item quantity="zero">%d items</item> <item quantity="one">%d item</item> @@ -270,6 +276,11 @@ <item quantity="one">%d second</item> <item quantity="other">%d seconds</item> </plurals> + <plurals name="minutes"> + <item quantity="zero">%s minutes</item> + <item quantity="one">%s minute</item> + <item quantity="other">%s minutes</item> + </plurals> <plurals name="movies"> <item quantity="zero">Movies</item> @@ -453,6 +464,11 @@ <string name="seerr_integration">Seerr integration</string> <string name="remove_seerr_server">Remove Seerr Server</string> <string name="seerr_server_added">Seerr server added</string> + <string name="quick_connect">Quick Connect</string> + <string name="quick_connect_summary">Authorize another device to log in to your account</string> + <string name="quick_connect_code">Enter Quick Connect code</string> + <string name="quick_connect_code_error">Code must be 6 digits</string> + <string name="quick_connect_success">Device authorized successfully</string> <string name="password">Password</string> <string name="username">Username</string> <string name="url">URL</string> @@ -462,105 +478,246 @@ <string name="request_4k">Request in 4K</string> <string name="software_decoding_av1">AV1 software decoding</string> <string name="upgrade_mpv_toast">MPV is now the default player except for HDR.\nYou can change this in settings.</string> + <string name="hdr_subtitle_style">HDR subtitle style</string> + <string name="image_subtitle_opacity">Image subtitle opacity</string> + <string name="brightness">Brightness</string> + <string name="contrast">Constrast</string> + <string name="saturation">Saturation</string> + <string name="hue">Hue</string> + <string name="red">Red</string> + <string name="green">Green</string> + <string name="blue">Blue</string> + <string name="blur">Blur</string> + <string name="save_for_album">Save for album</string> + <string name="play_slideshow">Play slideshow</string> + <string name="stop_slideshow">Stop slideshow</string> + <string name="slideshow_at_beginning">At beginning</string> + <string name="no_more_images">No more photos</string> + <string name="rotate_left">Rotate left</string> + <string name="rotate_right">Rotate right</string> + <string name="zoom_in">Zoom in</string> + <string name="zoom_out">Zoom out</string> + <string name="slideshow_duration">Slideshow duration</string> + <string name="play_videos_during_slideshow">Play videos during slideshow</string> + <string name="send_media_info_log_to_server">Send media info log to server</string> + <string name="no_limit">No limit</string> + <string name="max_days_next_up">Max days in Next Up</string> + + <string name="add_row">Add row</string> + <string name="genres_in">Genres in %1$s</string> + <string name="recently_released_in">Recently released in %1$s</string> + <string name="height">Height</string> + <string name="apply_all_rows">Apply to all rows</string> + <string name="customize_home">Customize home page</string> + <string name="home_rows">Home rows</string> + <string name="load_from_server">Load from server user profile</string> + <string name="save_to_server">Save to server user profile</string> + <string name="load_from_web_client">Load from web client</string> + <string name="use_series">Use series image</string> + <string name="add_row_for">Add row for %1$s</string> + <string name="overwrite_server_settings">Overwrite settings on server?</string> + <string name="overwrite_local_settings">Overwrite local settings?</string> + <string name="for_episodes">For episodes</string> + <string name="suggestions_for">Suggestions for %1$s</string> + <string name="increase_all_cards_size">Increase size for all cards</string> + <string name="decrease_all_cards_size">Decrease size for all cards</string> + <string name="use_thumb_images">Use thumb images</string> + <string name="settings_saved">Settings saved</string> + <string name="display_presets">Display presets</string> + <string name="display_presets_description">Built-in presets to quickly style all rows</string> + <string name="customize_home_summary">Choose rows and images on the home page</string> + <string name="start_after">Start after</string> + <string name="animate">Animate</string> + <string name="max_age_rating">Max age rating</string> + <string name="no_max">No max</string> + <string name="for_all_ages">For all ages</string> + <string name="up_to_age">Up to age %s</string> + <string name="screensaver">Screensaver</string> + <string name="screensaver_settings">Screensaver settings</string> + <string name="start_screensaver">Start screensaver</string> + <string name="duration">Duration</string> + <string name="photos">Photos</string> + <string name="include_types">Include types</string> + <string name="include_types_summary">What types of items to show images for</string> + <string name="content_scale_fit">Fit</string> + <string name="content_scale_crop">Crop</string> + <string name="content_scale_fill">Fill</string> + <string name="content_scale_fill_width">Fill width</string> + <string name="content_scale_fill_height">Fill height</string> + <string name="display_preset_default">Wholphin Default</string> + <string name="display_preset_compact">Wholphin Compact</string> + <string name="display_preset_series_thumb">Series Thumb images</string> + <string name="display_preset_episode_thumbnails">Episode Thumbnail images</string> + + <string name="volume_lowest">Lowest</string> + <string name="volume_low">Low</string> + <string name="volume_medium">Medium</string> + <string name="volume_high">High</string> + <string name="volume_full">Full</string> <string-array name="theme_song_volume"> - <item>Disabled</item> - <item>Lowest</item> - <item>Low</item> - <item>Medium</item> - <item>High</item> - <item>Full</item> + <item>@string/disabled</item> + <item>@string/volume_lowest</item> + <item>@string/volume_low</item> + <item>@string/volume_medium</item> + <item>@string/volume_high</item> + <item>@string/volume_full</item> </string-array> + <string name="purple">Purple</string> + <string name="orange">Orange</string> + <string name="bold_blue">Bold Blue</string> + <string name="black">Black</string> <string-array name="app_theme_colors"> - <item>Purple</item> - <item>Blue</item> - <item>Green</item> - <item>Orange</item> - <item>Bold Blue</item> - <item>Black</item> + <item>@string/purple</item> + <item>@string/blue</item> + <item>@string/green</item> + <item>@string/orange</item> + <item>@string/bold_blue</item> + <item>@string/black</item> </string-array> + <string name="skip_ignore">Ignore</string> + <string name="skip_automatically">Skip automatically</string> + <string name="skip_ask">Ask to skip</string> <string-array name="skip_behaviors"> - <item>Ignore</item> - <item>Skip automatically</item> - <item>Ask to skip</item> + <item>@string/skip_ignore</item> + <item>@string/skip_automatically</item> + <item>@string/skip_ask</item> </string-array> <string-array name="content_scale"> - <item>Fit</item> - <item>None</item> - <item>Crop</item> - <item>Fill</item> - <item>Fill Width</item> - <item>Fill Height</item> + <item>@string/content_scale_fit</item> + <item>@string/none</item> + <item>@string/content_scale_crop</item> + <item>@string/content_scale_fill</item> + <item>@string/content_scale_fill_width</item> + <item>@string/content_scale_fill_height</item> </string-array> + <string name="ffmpeg_fallback">Only use FFmpeg if no built-in decoder exists</string> + <string name="ffmpeg_prefer">Prefer to use FFmpeg over built-in decoders</string> + <string name="ffmpeg_never">Never use FFmpeg decoders</string> <string-array name="ffmpeg_extension_options"> - <item>Only use FFmpeg if no built-in decoder exists</item> - <item>Prefer to use FFmpeg over built-in decoders</item> - <item>Never use FFmpeg decoders</item> + <item>@string/ffmpeg_fallback</item> + <item>@string/ffmpeg_prefer</item> + <item>@string/ffmpeg_never</item> </string-array> + <string name="next_up_playback_end">At the end of playback</string> + <string name="next_up_outro">During end credits/outro</string> <string-array name="show_next_up_when_options"> - <item>At the end of playback</item> - <item>During end credits/outro</item> + <item>@string/next_up_playback_end</item> + <item>@string/next_up_outro</item> </string-array> + <string name="white">White</string> + <string name="light_gray">Light gray</string> + <string name="dark_gray">Dark gray</string> + <string name="yellow">Yellow</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> <string-array name="font_colors"> - <item>White</item> - <item>Black</item> - <item>Light Gray</item> - <item>Dark Gray</item> - <item>Red</item> - <item>Yellow</item> - <item>Green</item> - <item>Cyan</item> - <item>Blue</item> - <item>Magenta</item> + <item>@string/white</item> + <item>@string/black</item> + <item>@string/light_gray</item> + <item>@string/dark_gray</item> + <item>@string/red</item> + <item>@string/yellow</item> + <item>@string/green</item> + <item>@string/cyan</item> + <item>@string/blue</item> + <item>@string/magenta</item> </string-array> + <string name="subtitle_edge_outline">Outline</string> + <string name="subtitle_edge_shadow">Shadow</string> <string-array name="subtitle_edge"> - <item>None</item> - <item>Outline</item> - <item>Shadow</item> + <item>@string/none</item> + <item>@string/subtitle_edge_outline</item> + <item>@string/subtitle_edge_shadow</item> </string-array> + <string name="background_style_wrap">Wrap</string> + <string name="background_style_boxed">Boxed</string> <string-array name="background_style"> - <item>None</item> - <item>Wrap</item> - <item>Boxed</item> + <item>@string/none</item> + <item>@string/background_style_wrap</item> + <item>@string/background_style_boxed</item> </string-array> + <string name="exoplayer" translatable="false">ExoPlayer</string> + <string name="mpv" translatable="false">MPV</string> + <string name="prefer_mpv">Prefer MPV</string> <string-array name="player_backend_options"> - <item>ExoPlayer</item> - <item>MPV</item> - <item>Prefer MPV</item> + <item>@string/exoplayer</item> + <item>@string/mpv</item> + <item>@string/prefer_mpv</item> </string-array> + <string name="player_backend_options_subtitles_prefer_mpv">Use ExoPlayer for HDR playback</string> <string-array name="player_backend_options_subtitles"> - <item /> - <item /> - <item>Use ExoPlayer for HDR playback</item> + <item /><!-- Intentionally blank --> + <item /><!-- Intentionally blank --> + <item>@string/player_backend_options_subtitles_prefer_mpv</item> </string-array> + <string name="aspect_ratios_poster">Poster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Square (1:1)</string> <string-array name="aspect_ratios"> - <item>Poster (2:3)</item> - <item>16:9</item> - <item>4:3</item> - <item>Square (1:1)</item> + <item>@string/aspect_ratios_poster</item> + <item>@string/aspect_ratios_16_9</item> + <item>@string/aspect_ratios_4_3</item> + <item>@string/aspect_ratios_square</item> </string-array> + <string name="image_type_primary">Primary</string> + <string name="image_type_thumb">Thumb</string> <string-array name="image_types"> - <item>Primary</item> - <item>Thumb</item> + <item>@string/image_type_primary</item> + <item>@string/image_type_thumb</item> <!-- <item>Banner</item>--> </string-array> + <string name="backdrop_style_dynamic">Image with dynamic color</string> + <string name="backdrop_style_image">Image only</string> + <string name="enter_url_api_key"><![CDATA[Enter URL & API Key]]></string> + <string name="api_key">API Key</string> + <string name="seerr_login">Login to Seerr server</string> + <string name="seerr_jellyfin_user">Jellyfin user</string> + <string name="seerr_local_user">Local user</string> + <string name="search_and_download_subtitles"><![CDATA[Search & download subtitles]]></string> + <string name="no_subtitles_found">No remote subtitles were found</string> + <string name="series_continueing">Present</string> + <string name="in_app_screensaver">Use in-app screensaver</string> + <string name="delete_item">Are you sure you want to delete this item?</string> + <string name="show_media_management">Show media management options</string> <string-array name="backdrop_style_options"> - <item>Image with dynamic color</item> - <item>Image only</item> - <item>None</item> + <item>@string/backdrop_style_dynamic</item> + <item>@string/backdrop_style_image</item> + <item>@string/none</item> </string-array> + <string-array name="screensaver_item_types"> + <item>@string/movies</item> + <item>@string/tv_shows</item> + <item>@string/photos</item> + </string-array> + + <string name="actor">Actor</string> + <string name="composer">Composer</string> + <string name="writer">Writer</string> + <string name="guest_star">Guest star</string> + <string name="producer">Producer</string> + <string name="conductor">Conductor</string> + <string name="lyricist">Lyricist</string> + <string name="arranger">Arranger</string> + <string name="engineer">Engineer</string> + <string name="mixer">Mixer</string> + <string name="creator">Creator</string> + <string name="artist">Artist</string> + <string name="search_for">Search %s</string> + </resources> 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 <damontecres@gmail.com> +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 @@ + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> +- <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> + <uses-permission + android:name="android.permission.WRITE_EXTERNAL_STORAGE" + android:maxSdkVersion="28" /> +@@ -17,7 +16,7 @@ + android:required="false" /> + <uses-feature + android:name="android.software.leanback" +- android:required="false" /> ++ android:required="true" /> + <uses-feature + android:name="android.hardware.microphone" + android:required="false" /> +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("<!-- app-note:(.+) -->") + +- 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 new file mode 100644 index 00000000..f8fc8445 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -0,0 +1,292 @@ +package com.github.damontecres.wholphin.services + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUser +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 +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID + +@OptIn(ExperimentalCoroutinesApi::class) +class SuggestionServiceTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private val mockApi = mockk<ApiClient>(relaxed = true) + private val mockServerRepository = mockk<ServerRepository>() + private val mockCache = mockk<SuggestionsCache>() + private val mockWorkManager = mockk<WorkManager>() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + io.mockk.unmockkObject(GetItemsRequestHandler) + } + + private fun createService() = + SuggestionService( + api = mockApi, + serverRepository = mockServerRepository, + cache = mockCache, + workManager = mockWorkManager, + ) + + private fun mockQueryResult(items: List<BaseItemDto>): Response<BaseItemDtoQueryResult> = + mockk { + every { content } returns + mockk { + every { this@mockk.items } returns items + } + } + + private fun mockUser(id: UUID = UUID.randomUUID()): JellyfinUser = + JellyfinUser( + id = id, + name = "TestUser", + serverId = UUID.randomUUID(), + accessToken = "token", + ) + + private fun mockWorkInfo(state: WorkInfo.State): WorkInfo = mockk<WorkInfo> { every { this@mockk.state } returns state } + + @Test + fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() = + runTest { + val currentUser = MutableLiveData<JellyfinUser?>(null) + every { mockServerRepository.currentUser } returns currentUser + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + + val service = createService() + val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Empty, result) + } + + @Test + fun maps_active_work_states_to_Loading() = + runTest { + listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state -> + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + flowOf(listOf(mockWorkInfo(state))) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Loading, result) + } + } + + @Test + fun maps_finished_work_states_to_Empty() = + runTest { + listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state -> + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + flowOf(listOf(mockWorkInfo(state))) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Empty, result) + } + } + + @Test + fun getSuggestionsFlow_returnsEmpty_whenCacheEmptyAndNoWorkInfo() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Empty, result) + } + + @Test + fun getSuggestionsFlow_returnsEmpty_whenCachedIdsEmpty_evenIfWorkIsEnqueued() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData<JellyfinUser?>(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<JellyfinUser?>(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 { + val userId = UUID.randomUUID() + val libraryId = UUID.randomUUID() + val otherLibraryId = UUID.randomUUID() + val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + + coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null + coEvery { + mockCache.get( + userId, + otherLibraryId, + BaseItemKind.MOVIE, + ) + } returns CachedSuggestions(listOf(UUID.randomUUID())) + + val service = createService() + assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first()) + + coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null + coEvery { + mockCache.get( + userId, + libraryId, + BaseItemKind.SERIES, + ) + } returns CachedSuggestions(listOf(UUID.randomUUID())) + + assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first()) + } + + @Test + fun getSuggestionsFlow_returnsSuccess_whenCacheHasItems() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + + val cachedId = UUID.randomUUID() + coEvery { + mockCache.get( + userId, + parentId, + BaseItemKind.MOVIE, + ) + } returns CachedSuggestions(listOf(cachedId)) + + val dto = + mockk<BaseItemDto>(relaxed = true) { + every { id } returns cachedId + every { type } returns BaseItemKind.MOVIE + } + io.mockk.mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto)) + + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertTrue(result is SuggestionsResource.Success) + val items = (result as SuggestionsResource.Success).items + assertEquals(1, items.size) + assertEquals(cachedId, items[0].id) + } + + @Test + fun getSuggestionsFlow_emitsEmpty_whenApiFails() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + + val cachedId = UUID.randomUUID() + coEvery { + mockCache.get( + userId, + parentId, + BaseItemKind.MOVIE, + ) + } returns CachedSuggestions(listOf(cachedId)) + + io.mockk.mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } throws RuntimeException("Network error") + + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Empty, result) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt new file mode 100644 index 00000000..82e3f2c8 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt @@ -0,0 +1,233 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.mayakapps.kache.ObjectKache +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.UUID +import kotlin.io.path.createTempDirectory + +class SuggestionsCacheTest { + private val tempDir = createTempDirectory("suggestions-cache-test").toFile() + + @After + fun tearDown() { + tempDir.deleteRecursively() + } + + private fun testCacheWithTempDir(): SuggestionsCache { + val mockContext = mockk<Context>(relaxed = true) + every { mockContext.cacheDir } returns tempDir + return SuggestionsCache(mockContext) + } + + private fun memoryCacheOf(cache: SuggestionsCache): ObjectKache<String, CachedSuggestions> { + val field = SuggestionsCache::class.java.getDeclaredField("memoryCache") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + return field.get(cache) as ObjectKache<String, CachedSuggestions> + } + + @Test + fun putThenGet_returnsCachedSuggestions() = + runTest { + val cache = testCacheWithTempDir() + val userId = UUID.randomUUID() + val libId = UUID.randomUUID() + + cache.put(userId, libId, BaseItemKind.MOVIE, emptyList()) + + val loaded = cache.get(userId, libId, BaseItemKind.MOVIE) + assertNotNull(loaded) + assertEquals(0, loaded!!.ids.size) + } + + @Test + fun get_readsFromDisk_whenMemoryAbsent() = + runTest { + val cache1 = testCacheWithTempDir() + val userId = UUID.randomUUID() + val libId = UUID.randomUUID() + + cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList()) + + // Create a fresh instance which won't have the memory entry + val cache2 = testCacheWithTempDir() + // memoryCache should be empty + assertTrue(memoryCacheOf(cache2).isEmpty()) + + val loaded = cache2.get(userId, libId, BaseItemKind.MOVIE) + assertNotNull(loaded) + assertEquals(0, loaded!!.ids.size) + // After read, memory cache should contain the entry + assertTrue(memoryCacheOf(cache2).isNotEmpty()) + } + + // LRU behavior is not enforced in production; keep tests focused on public behavior. + @Test + fun memoryCache_respectsLruLimit() = + runTest { + val cache = testCacheWithTempDir() + val userId = UUID.randomUUID() + + // Insert MAX + 2 entries and ensure size never exceeds limit + val limit = 8 // keep in sync with implementation + val libIds = mutableListOf<UUID>() + for (i in 0 until (limit + 2)) { + val libId = UUID.randomUUID() + libIds.add(libId) + cache.put(userId, libId, BaseItemKind.MOVIE, emptyList()) + } + + // memoryCache should be bounded to the limit + val mem = memoryCacheOf(cache) + assertTrue(mem.size <= limit) + // The oldest (first inserted) should be evicted from memory cache + val firstKey = "${userId}_${libIds.first()}_${BaseItemKind.MOVIE.serialName}" + assertFalse(mem.containsKey(firstKey)) + } + + // Library isolation tests - verify different parentIds/itemKinds don't mix + + @Test + fun differentParentIds_returnIsolatedCacheEntries() = + runTest { + val cache = testCacheWithTempDir() + val userId = UUID.randomUUID() + val movieLibraryId = UUID.randomUUID() + val tvLibraryId = UUID.randomUUID() + + val movieIds = List(2) { UUID.randomUUID() } + val tvIds = List(3) { UUID.randomUUID() } + + cache.put(userId, movieLibraryId, BaseItemKind.MOVIE, movieIds) + cache.put(userId, tvLibraryId, BaseItemKind.MOVIE, tvIds) + + val loadedMovies = cache.get(userId, movieLibraryId, BaseItemKind.MOVIE) + val loadedTv = cache.get(userId, tvLibraryId, BaseItemKind.MOVIE) + + assertNotNull(loadedMovies) + assertNotNull(loadedTv) + assertEquals(2, loadedMovies!!.ids.size) + assertEquals(3, loadedTv!!.ids.size) + assertEquals(movieIds[0], loadedMovies.ids[0]) + assertEquals(tvIds[0], loadedTv.ids[0]) + } + + @Test + fun differentItemKinds_returnIsolatedCacheEntries() = + runTest { + val cache = testCacheWithTempDir() + val userId = UUID.randomUUID() + val libraryId = UUID.randomUUID() + + val movieIds = listOf(UUID.randomUUID()) + val seriesIds = List(2) { UUID.randomUUID() } + + cache.put(userId, libraryId, BaseItemKind.MOVIE, movieIds) + cache.put(userId, libraryId, BaseItemKind.SERIES, seriesIds) + + val loadedMovies = cache.get(userId, libraryId, BaseItemKind.MOVIE) + val loadedSeries = cache.get(userId, libraryId, BaseItemKind.SERIES) + + assertNotNull(loadedMovies) + assertNotNull(loadedSeries) + assertEquals(1, loadedMovies!!.ids.size) + assertEquals(2, loadedSeries!!.ids.size) + assertEquals(movieIds[0], loadedMovies.ids[0]) + assertEquals(seriesIds[0], loadedSeries.ids[0]) + } + + @Test + fun rapidLibrarySwitching_maintainsIsolation() = + runTest { + val cache = testCacheWithTempDir() + val userId = UUID.randomUUID() + val lib1 = UUID.randomUUID() + val lib2 = UUID.randomUUID() + val lib3 = UUID.randomUUID() + + val ids1 = listOf(UUID.randomUUID()) + val ids2 = listOf(UUID.randomUUID()) + val ids3 = listOf(UUID.randomUUID()) + + // Simulate rapid switching: put -> get -> put -> get pattern + cache.put(userId, lib1, BaseItemKind.MOVIE, ids1) + assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + + cache.put(userId, lib2, BaseItemKind.MOVIE, ids2) + assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + + // Switch back to lib1 - should still have correct data + assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + + cache.put(userId, lib3, BaseItemKind.MOVIE, ids3) + assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + + // Verify all libraries still have correct data after rapid switching + assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + } + + @Test + fun libraryIsolation_persistsToDisk() = + runTest { + val userId = UUID.randomUUID() + val lib1 = UUID.randomUUID() + val lib2 = UUID.randomUUID() + + val ids1 = listOf(UUID.randomUUID()) + val ids2 = listOf(UUID.randomUUID()) + + // Write with first cache instance + val cache1 = testCacheWithTempDir() + cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1) + cache1.put(userId, lib2, BaseItemKind.SERIES, ids2) + + // Read with fresh cache instance (empty memory cache, reads from disk) + val cache2 = testCacheWithTempDir() + assertTrue(memoryCacheOf(cache2).isEmpty()) + + val loaded1 = cache2.get(userId, lib1, BaseItemKind.MOVIE) + val loaded2 = cache2.get(userId, lib2, BaseItemKind.SERIES) + + assertNotNull(loaded1) + assertNotNull(loaded2) + assertEquals(ids1[0], loaded1!!.ids[0]) + assertEquals(ids2[0], loaded2!!.ids[0]) + } + + @Test + fun differentUsers_returnIsolatedCacheEntries() = + runTest { + val cache = testCacheWithTempDir() + val user1 = UUID.randomUUID() + val user2 = UUID.randomUUID() + val libraryId = UUID.randomUUID() + + val user1Ids = listOf(UUID.randomUUID()) + val user2Ids = List(2) { UUID.randomUUID() } + + cache.put(user1, libraryId, BaseItemKind.MOVIE, user1Ids) + cache.put(user2, libraryId, BaseItemKind.MOVIE, user2Ids) + + val loadedUser1 = cache.get(user1, libraryId, BaseItemKind.MOVIE) + val loadedUser2 = cache.get(user2, libraryId, BaseItemKind.MOVIE) + + assertNotNull(loadedUser1) + assertNotNull(loadedUser2) + assertEquals(1, loadedUser1!!.ids.size) + assertEquals(2, loadedUser2!!.ids.size) + assertEquals(user1Ids[0], loadedUser1.ids[0]) + assertEquals(user2Ids[0], loadedUser2.ids[0]) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt new file mode 100644 index 00000000..0b48a011 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -0,0 +1,171 @@ +package com.github.damontecres.wholphin.services + +import androidx.appcompat.app.AppCompatActivity +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.MutableLiveData +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.google.common.util.concurrent.ListenableFuture +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID + +@OptIn(ExperimentalCoroutinesApi::class) +class SuggestionsSchedulerServiceTest { + @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + private val currentLiveData = MutableLiveData<CurrentUser?>() + private val mockActivity = mockk<AppCompatActivity>(relaxed = true) + private val mockServerRepository = mockk<ServerRepository>(relaxed = true) + private val mockWorkManager = mockk<WorkManager>(relaxed = true) + private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true)) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { mockActivity.lifecycle } returns lifecycleRegistry + every { mockServerRepository.current } returns currentLiveData + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun createService() = + SuggestionsSchedulerService( + context = mockActivity, + serverRepository = mockServerRepository, + workManager = mockWorkManager, + ).also { + it.dispatcher = testDispatcher + it.initialDelaySecondsProvider = { 60L } + } + + private fun mockWorkInfos(infos: List<androidx.work.WorkInfo>) { + @Suppress("UNCHECKED_CAST") + val future = mockk<ListenableFuture<List<androidx.work.WorkInfo>>>() + every { future.get() } returns infos + every { mockWorkManager.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) } returns future + } + + @Test + fun schedules_periodic_work_when_user_present() = + runTest { + mockWorkInfos(emptyList()) + createService() + currentLiveData.value = + CurrentUser( + user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), + server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), + ) + advanceUntilIdle() + verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } + } + + @Test + fun cancels_work_when_user_null() = + runTest { + mockWorkInfos(emptyList()) + createService() + currentLiveData.value = + CurrentUser( + user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), + server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), + ) + advanceUntilIdle() + currentLiveData.value = null + advanceUntilIdle() + verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) } + } + + @Test + fun schedules_periodic_work_with_delay_when_cache_empty() = + runTest { + mockWorkInfos(emptyList()) + val workRequestSlot = slot<PeriodicWorkRequest>() + every { + mockWorkManager.enqueueUniquePeriodicWork( + SuggestionsWorker.WORK_NAME, + any(), + capture(workRequestSlot), + ) + } returns mockk() + + createService() + currentLiveData.value = + CurrentUser( + user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), + server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), + ) + advanceUntilIdle() + + verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } + assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) + } + + @Test + fun schedules_periodic_work_with_delay_when_cache_not_empty() = + runTest { + mockWorkInfos(emptyList()) + val workRequestSlot = slot<PeriodicWorkRequest>() + every { + mockWorkManager.enqueueUniquePeriodicWork( + SuggestionsWorker.WORK_NAME, + any(), + capture(workRequestSlot), + ) + } returns mockk() + + createService() + currentLiveData.value = + CurrentUser( + user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), + server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), + ) + advanceUntilIdle() + + verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } + assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) + } + + @Test + fun does_not_schedule_if_already_scheduled_for_same_user() = + runTest { + val userId = UUID.randomUUID() + val workInfo = mockk<androidx.work.WorkInfo>() + every { workInfo.state } returns androidx.work.WorkInfo.State.ENQUEUED + every { workInfo.tags } returns setOf("user:$userId") + mockWorkInfos(listOf(workInfo)) + + createService() + currentLiveData.value = + CurrentUser( + user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"), + server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), + ) + advanceUntilIdle() + + verify(exactly = 0) { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt new file mode 100644 index 00000000..5679777e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -0,0 +1,233 @@ +package com.github.damontecres.wholphin.services + +import androidx.datastore.core.DataStore +import androidx.lifecycle.MutableLiveData +import androidx.work.Data +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.UserViewsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.UUID + +class SuggestionsWorkerTest { + private val testUserId = UUID.randomUUID() + private val testServerId = UUID.randomUUID() + private val mockWorkerParams = mockk<WorkerParameters>(relaxed = true) + private val mockServerRepository = mockk<ServerRepository>(relaxed = true) + private val mockPreferences = mockk<DataStore<AppPreferences>>(relaxed = true) + private val mockApi = mockk<ApiClient>(relaxed = true) + private val mockCache = mockk<SuggestionsCache>(relaxed = true) + private val mockUserViewsApi = mockk<UserViewsApi>(relaxed = true) + + @Before + fun setUp() { + every { mockApi.userViewsApi } returns mockUserViewsApi + every { mockApi.baseUrl } returns "http://localhost" + every { mockApi.accessToken } returns "test-token" + coEvery { mockCache.get(any(), any(), any()) } returns null + } + + @After + fun tearDown() = unmockkObject(GetItemsRequestHandler) + + private fun createWorker( + userId: UUID? = testUserId, + serverId: UUID? = testServerId, + ): SuggestionsWorker { + val inputData = + Data + .Builder() + .apply { + userId?.let { putString(SuggestionsWorker.PARAM_USER_ID, it.toString()) } + serverId?.let { putString(SuggestionsWorker.PARAM_SERVER_ID, it.toString()) } + }.build() + every { mockWorkerParams.inputData } returns inputData + return SuggestionsWorker( + context = mockk(relaxed = true), + workerParams = mockWorkerParams, + serverRepository = mockServerRepository, + preferences = mockPreferences, + api = mockApi, + cache = mockCache, + ) + } + + private fun mockPrefs() = + mockk<AppPreferences>(relaxed = true) { + every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 } + } + + @Test + fun returns_failure_on_invalid_input() = + runTest { + listOf( + createWorker(userId = null), + createWorker(serverId = null), + ).forEach { worker -> + assertEquals(ListenableWorker.Result.failure(), worker.doWork()) + } + } + + @Test + fun restores_session_when_api_not_configured() = + runTest { + every { mockApi.baseUrl } returns null + every { mockApi.accessToken } returns null + val mockUser = mockk<CurrentUser>() + var restored = false + every { mockServerRepository.current } answers { MutableLiveData(if (restored) mockUser else null) } + coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers { + restored = true + mockUser + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult() + + val result = createWorker().doWork() + + coVerify { mockServerRepository.restoreSession(testServerId, testUserId) } + assertEquals(ListenableWorker.Result.success(), result) + } + + @Test + fun caches_suggestions_for_supported_types() = + runTest { + listOf( + CollectionType.MOVIES to BaseItemKind.MOVIE, + CollectionType.TVSHOWS to BaseItemKind.SERIES, + ).forEach { (collectionType, itemKind) -> + val viewId = UUID.randomUUID() + val view = + mockk<BaseItemDto>(relaxed = true) { + every { id } returns viewId + every { this@mockk.collectionType } returns collectionType + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) + mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(mockk(relaxed = true))) + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify { mockCache.put(testUserId, viewId, itemKind, any()) } + } + } + + @Test + fun fetches_contextual_suggestions_when_genres_available() = + runTest { + val viewId = UUID.randomUUID() + val view = + mockk<BaseItemDto>(relaxed = true) { + every { id } returns viewId + every { this@mockk.collectionType } returns CollectionType.MOVIES + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) + mockkObject(GetItemsRequestHandler) + + val genreId = UUID.randomUUID() + val historyItem = + mockk<BaseItemDto>(relaxed = true) { + every { id } returns UUID.randomUUID() + every { genreItems } returns listOf(mockk { every { id } returns genreId }) + } + val contextualItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() } + val randomItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() } + val freshItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() } + + var callCount = 0 + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } answers { + callCount++ + when (callCount) { + 1 -> mockQueryResult(listOf(historyItem)) + 2 -> mockQueryResult(listOf(contextualItem)) + 3 -> mockQueryResult(listOf(randomItem)) + 4 -> mockQueryResult(listOf(freshItem)) + else -> mockQueryResult(emptyList()) + } + } + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, any()) } + } + + @Test + fun skips_contextual_suggestions_when_no_genres_available() = + runTest { + val viewId = UUID.randomUUID() + val view = + mockk<BaseItemDto>(relaxed = true) { + every { id } returns viewId + every { this@mockk.collectionType } returns CollectionType.MOVIES + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) + mockkObject(GetItemsRequestHandler) + + val historyItem = + mockk<BaseItemDto>(relaxed = true) { + every { id } returns UUID.randomUUID() + every { genreItems } returns emptyList() + } + val randomItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() } + val freshItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() } + + var callCount = 0 + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } answers { + callCount++ + when (callCount) { + 1 -> mockQueryResult(listOf(historyItem)) + 2 -> mockQueryResult(listOf(randomItem)) + 3 -> mockQueryResult(listOf(freshItem)) + else -> mockQueryResult(emptyList()) + } + } + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, any()) } + } + + @Test + fun returns_retry_on_network_error() = + runTest { + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } throws ApiClientException("Network error") + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + } +} + +fun mockQueryResult(items: List<BaseItemDto> = emptyList()) = + mockk<org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult>>(relaxed = true) { + every { content } returns mockk { every { this@mockk.items } returns items } + } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt b/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt new file mode 100644 index 00000000..b0a8810f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt @@ -0,0 +1,23 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString +import org.junit.Assert +import org.junit.Test + +class FormattingTests { + @Test + fun testResolutionStrings() { + Assert.assertEquals("4K", resolutionString(3840, 2160, false)) + Assert.assertEquals("1080p", resolutionString(1920, 1080, false)) + Assert.assertEquals("720p", resolutionString(1280, 720, false)) + Assert.assertEquals("480i", resolutionString(640, 480, true)) + + Assert.assertEquals("576p", resolutionString(1024, 576, false)) + Assert.assertEquals("576p", resolutionString(960, 576, false)) + + // 21:9 + Assert.assertEquals("1080p", resolutionString(1920, 822, false)) + + Assert.assertEquals("1440p", resolutionString(2560, 1440, false)) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt new file mode 100644 index 00000000..0e9bb92e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt @@ -0,0 +1,136 @@ +package com.github.damontecres.wholphin.test + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.updateHomePagePreferences +import com.github.damontecres.wholphin.services.DatePlayedService +import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.services.mockQueryResult +import io.mockk.CapturingSlot +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.operations.TvShowsApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.time.LocalDate + +class NextUpTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private val mockTvShowsApi = mockk<TvShowsApi>() + private val mockApi = mockk<ApiClient>(relaxed = true) + private val mockContext = mockk<Context>() + private val mockDatePlayedService = mockk<DatePlayedService>() + + private val latestNextUpService = + LatestNextUpService(mockContext, mockApi, mockDatePlayedService) + + @Before + fun setUp() { + every { mockApi.tvShowsApi } returns mockTvShowsApi + } + + @Test + fun `Test max 30 days in next up`() = + runTest { + val maxDays = 30 + val nextUpSlot = CapturingSlot<GetNextUpRequest>() + coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult() + latestNextUpService.getNextUp( + userId = UUID.randomUUID(), + limit = 10, + enableRewatching = true, + enableResumable = true, + maxDays = maxDays, + ) + Assert.assertEquals(10, nextUpSlot.captured.limit) + val expected = LocalDate.now().minusDays(maxDays.toLong()) + Assert.assertEquals(expected, nextUpSlot.captured.nextUpDateCutoff?.toLocalDate()) + } + + @Test + fun `Test no limit in next up`() = + runTest { + val nextUpSlot = CapturingSlot<GetNextUpRequest>() + coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult() + latestNextUpService.getNextUp( + userId = UUID.randomUUID(), + limit = 10, + enableRewatching = true, + enableResumable = true, + maxDays = -1, + ) + Assert.assertEquals(10, nextUpSlot.captured.limit) + Assert.assertNull(nextUpSlot.captured.nextUpDateCutoff) + } + + @Test + fun `Test storing preference`() { + AppPreference.MaxDaysNextUp.setter.invoke(AppPreferences.getDefaultInstance(), 0).let { + Assert.assertEquals(7, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke( + AppPreferences.getDefaultInstance(), + AppPreference.MaxDaysNextUpOptions.lastIndex.toLong(), + ).let { + Assert.assertEquals(365, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke(AppPreferences.getDefaultInstance(), 3) + .let { + Assert.assertEquals(60, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke( + AppPreferences.getDefaultInstance(), + AppPreference.MaxDaysNextUpOptions.lastIndex + 1L, + ).let { + Assert.assertEquals(-1, it.homePagePreferences.maxDaysNextUp) + } + } + + @Test + fun `Test getting preference`() { + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = 7 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(0, result) + } + + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = 60 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(3, result) + } + + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = -1 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(AppPreference.MaxDaysNextUpOptions.lastIndex + 1L, result) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt index 7af1bfce..d7e4abfd 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt @@ -15,7 +15,25 @@ class TestDisplayModeChoice { val UHD_30 = DisplayMode(4, 3840, 2160, 30f) val UHD_24 = DisplayMode(5, 3840, 2160, 24f) - val ALL_MODES = listOf(UHD_24, UHD_30, UHD_60, HD_24, HD_30, HD_60) + val HD720_60 = DisplayMode(6, 1280, 720, 60f) + val HD720_30 = DisplayMode(7, 1280, 720, 30f) + val HD720_24 = DisplayMode(8, 1280, 720, 24f) + + val ALL_MODES = + listOf( + UHD_24, + UHD_30, + UHD_60, + HD_24, + HD_30, + HD_60, + HD720_60, + HD720_30, + HD720_24, + ).sortedWith( + compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) } @Test @@ -32,7 +50,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(3, result?.modeId) + Assert.assertEquals(UHD_60.modeId, result?.modeId) } @Test @@ -49,7 +67,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = true, ) - Assert.assertEquals(0, result?.modeId) + Assert.assertEquals(HD_60.modeId, result?.modeId) } @Test @@ -66,7 +84,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(4, result?.modeId) + Assert.assertEquals(UHD_30.modeId, result?.modeId) } @Test @@ -83,14 +101,14 @@ class TestDisplayModeChoice { refreshRateSwitch = false, resolutionSwitch = true, ) - Assert.assertEquals(1, result?.modeId) + Assert.assertEquals(HD_30.modeId, result?.modeId) } @Test fun testFraction() { val streamWidth = 1920 val streamHeight = 1080 - val streamRealFrameRate = 30f + val streamRealFrameRate = 29.970f val displayModes = listOf( @@ -104,7 +122,7 @@ class TestDisplayModeChoice { displayModes = displayModes, streamWidth = streamWidth, streamHeight = streamHeight, - targetFrameRate = 29.970f, + targetFrameRate = streamRealFrameRate, refreshRateSwitch = true, resolutionSwitch = false, ) @@ -119,6 +137,136 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(1, result2?.modeId) + Assert.assertEquals(HD_30.modeId, result2?.modeId) + } + + private fun test( + expected: DisplayMode, + streamWidth: Int, + streamHeight: Int, + streamRealFrameRate: Float, + ) { + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = false, + resolutionSwitch = true, + ) + Assert.assertEquals( + "streamWidth=$streamWidth, streamHeight=$streamHeight, streamRealFrameRate=$streamRealFrameRate", + expected.modeId, + result?.modeId, + ) + } + + @Test + fun `Test choose best resolution`() { + test(HD720_30, 1280, 720, 30f) + test(HD720_30, 1280, 548, 30f) + test(HD720_30, 640, 480, 30f) + test(HD720_30, 960, 720, 30f) + test(HD720_24, 960, 720, 24f) + } + + @Test + fun `Test 60fps for 24 without resolution switch`() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 24f + + val displayModes = + listOf( + UHD_60, + HD_60, + HD_30, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(UHD_60.modeId, result?.modeId) + } + + @Test + fun `Test 60fps for 24 with resolution switch`() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 24f + + val displayModes = + listOf( + HD_60, + HD_30, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_60.modeId, result?.modeId) + } + + @Test + fun `Test prefer refresh rate over resolution`() { + val streamWidth = 1280 + val streamHeight = 720 + val streamRealFrameRate = 24f + + // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate + val displayModes = + listOf( + HD_24, + HD720_60, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_24.modeId, result?.modeId) + } + + @Test + fun `Test prefer refresh rate over resolution2`() { + val streamWidth = 1280 + val streamHeight = 720 + val streamRealFrameRate = 30f + + // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate + val displayModes = + listOf( + HD_30, + HD720_60, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_30.modeId, result?.modeId) } } 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<KClass<out HomeRowConfig>>() + 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<List<HomeRowConfig>>(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/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt index 76893f8c..a4acf93f 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt @@ -62,4 +62,32 @@ class TestSeerr { ) Assert.assertEquals(expected, urls) } + + @Test + fun testCreateUrls5() { + val urls = + createUrls("10.0.0.2:443") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2:443/api/v1", + "https://10.0.0.2/api/v1", + ) + Assert.assertEquals(expected, urls) + } + + @Test + fun testCreateUrls6() { + val urls = + createUrls("10.0.0.2:8080") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2:8080/api/v1", + "https://10.0.0.2:8080/api/v1", + ) + Assert.assertEquals(expected, urls) + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt new file mode 100644 index 00000000..5027aee8 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt @@ -0,0 +1,295 @@ +package com.github.damontecres.wholphin.test + +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.NameGuidPair +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** + * Tests for the suggestions deduplication and genre collection logic used in + * RecommendedMovie and RecommendedTvShow ViewModels. + */ +class TestSuggestionsDeduplication { + @Test + fun `deduplication by seriesId groups episodes of same series`() { + val seriesId = UUID.randomUUID() + val items = + listOf( + episode(seriesId = seriesId, name = "Episode 1"), + episode(seriesId = seriesId, name = "Episode 2"), + episode(seriesId = seriesId, name = "Episode 3"), + movie(name = "Some Movie"), + ) + + val deduplicated = + items + .distinctBy { it.seriesId ?: it.id } + .take(3) + + // Should have 2 items: one series entry and one movie + assertEquals(2, deduplicated.size) + } + + @Test + fun `deduplication preserves order - most recent first`() { + val series1 = UUID.randomUUID() + val series2 = UUID.randomUUID() + val items = + listOf( + episode(seriesId = series1, name = "S1 Episode 1"), // Most recent + episode(seriesId = series1, name = "S1 Episode 2"), + episode(seriesId = series2, name = "S2 Episode 1"), + movie(name = "Old Movie"), + ) + + val deduplicated = + items + .distinctBy { it.seriesId ?: it.id } + .take(3) + + assertEquals(3, deduplicated.size) + // First item should be from series1 (most recent) + assertEquals(series1, deduplicated[0].seriesId) + assertEquals(series2, deduplicated[1].seriesId) + } + + @Test + fun `movies use their own id for deduplication`() { + val items = + listOf( + movie(name = "Movie 1"), + movie(name = "Movie 2"), + movie(name = "Movie 3"), + ) + + val deduplicated = + items + .distinctBy { it.seriesId ?: it.id } + .take(3) + + // All movies should be kept since they have unique IDs + assertEquals(3, deduplicated.size) + } + + @Test + fun `take 3 limits seed items`() { + val items = + listOf( + movie(name = "Movie 1"), + movie(name = "Movie 2"), + movie(name = "Movie 3"), + movie(name = "Movie 4"), + movie(name = "Movie 5"), + ) + + val deduplicated = + items + .distinctBy { it.seriesId ?: it.id } + .take(3) + + assertEquals(3, deduplicated.size) + } +} + +class TestSuggestionsGenreCollection { + @Test + fun `collects genres from multiple seed items`() { + val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action") + val genre2 = NameGuidPair(id = UUID.randomUUID(), name = "Comedy") + val genre3 = NameGuidPair(id = UUID.randomUUID(), name = "Drama") + + val seedItems = + listOf( + movie(name = "Action Movie", genres = listOf(genre1)), + movie(name = "Comedy Movie", genres = listOf(genre2)), + movie(name = "Drama Movie", genres = listOf(genre3)), + ) + + val allGenreIds = + seedItems + .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } + .distinct() + + assertEquals(3, allGenreIds.size) + assertTrue(allGenreIds.contains(genre1.id)) + assertTrue(allGenreIds.contains(genre2.id)) + assertTrue(allGenreIds.contains(genre3.id)) + } + + @Test + fun `deduplicates shared genres across seed items`() { + val sharedGenre = NameGuidPair(id = UUID.randomUUID(), name = "Action") + val uniqueGenre = NameGuidPair(id = UUID.randomUUID(), name = "Comedy") + + val seedItems = + listOf( + movie(name = "Action Movie 1", genres = listOf(sharedGenre)), + movie(name = "Action Movie 2", genres = listOf(sharedGenre)), + movie(name = "Action Comedy", genres = listOf(sharedGenre, uniqueGenre)), + ) + + val allGenreIds = + seedItems + .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } + .distinct() + + // Should only have 2 unique genres + assertEquals(2, allGenreIds.size) + } + + @Test + fun `handles items with no genres`() { + val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action") + + val seedItems = + listOf( + movie(name = "Movie with genre", genres = listOf(genre1)), + movie(name = "Movie without genre", genres = null), + movie(name = "Movie with empty genres", genres = emptyList()), + ) + + val allGenreIds = + seedItems + .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } + .distinct() + + assertEquals(1, allGenreIds.size) + assertEquals(genre1.id, allGenreIds[0]) + } + + @Test + fun `returns empty list when no seed items have genres`() { + val seedItems = + listOf( + movie(name = "Movie 1", genres = null), + movie(name = "Movie 2", genres = emptyList()), + ) + + val allGenreIds = + seedItems + .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } + .distinct() + + assertTrue(allGenreIds.isEmpty()) + } +} + +class TestSuggestionsExcludeIds { + @Test + fun `excludeIds contains all seed item ids`() { + val seedItems = + listOf( + movie(name = "Movie 1"), + movie(name = "Movie 2"), + movie(name = "Movie 3"), + ) + + val excludeIds = seedItems.map { it.id } + + assertEquals(3, excludeIds.size) + seedItems.forEach { item -> + assertTrue(excludeIds.contains(item.id)) + } + } +} + +@RunWith(Parameterized::class) +class TestSuggestionsCombineAndDeduplicate( + private val contextual: List<BaseItemDto>, + private val random: List<BaseItemDto>, + private val fresh: List<BaseItemDto>, + private val expectedUniqueCount: Int, + private val description: String, +) { + @Test + fun `combine and deduplicate works correctly`() { + val combined = + (contextual + random + fresh) + .distinctBy { it.id } + + assertEquals(description, expectedUniqueCount, combined.size) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{4}") + fun data(): Collection<Array<Any>> { + val movie1 = movie(name = "Movie 1") + val movie2 = movie(name = "Movie 2") + val movie3 = movie(name = "Movie 3") + val movie4 = movie(name = "Movie 4") + + return listOf( + arrayOf( + listOf(movie1, movie2), + listOf(movie3), + listOf(movie4), + 4, + "no duplicates - all 4 unique", + ), + arrayOf( + listOf(movie1, movie2), + listOf(movie1, movie3), + listOf(movie2, movie4), + 4, + "with duplicates - deduplicates to 4", + ), + arrayOf( + listOf(movie1), + listOf(movie1), + listOf(movie1), + 1, + "all same - deduplicates to 1", + ), + arrayOf( + emptyList<BaseItemDto>(), + listOf(movie1, movie2), + listOf(movie3), + 3, + "empty contextual - still combines others", + ), + arrayOf( + emptyList<BaseItemDto>(), + emptyList<BaseItemDto>(), + emptyList<BaseItemDto>(), + 0, + "all empty - returns empty", + ), + ) + } + } +} + +// Helper functions to create test data + +private fun movie( + id: UUID = UUID.randomUUID(), + name: String = "Test Movie", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.MOVIE, + name = name, + seriesId = null, + genreItems = genres, + ) + +private fun episode( + id: UUID = UUID.randomUUID(), + seriesId: UUID, + name: String = "Test Episode", + genres: List<NameGuidPair>? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.EPISODE, + name = name, + seriesId = seriesId, + genreItems = genres, + ) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt new file mode 100644 index 00000000..66178a4e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -0,0 +1,238 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.test.TestActivity +import com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchServerViewModel +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +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.operations.QuickConnectApi +import org.jellyfin.sdk.discovery.DiscoveryService +import org.jellyfin.sdk.discovery.RecommendedServerInfo +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.inject.Inject + +@HiltAndroidTest +@Config(application = HiltTestApplication::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class BasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule<TestActivity>() + + @Inject + lateinit var jellyfin: Jellyfin + + @Inject + lateinit var api: ApiClient + + @Inject + lateinit var setupNavigationManager: SetupNavigationManager + + lateinit var screensaverService: ScreensaverService + + lateinit var switchServerViewModel: SwitchServerViewModel + + val discovery: DiscoveryService = mockk() + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + Dispatchers.setMain(TestModule.testDispatcher) + mockkStatic(Dispatchers::class) + + every { Dispatchers.IO } returns TestModule.testDispatcher + every { Dispatchers.Default } returns TestModule.testDispatcher + + hiltRule.inject() + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns + MutableStateFlow( + ScreensaverState( + false, + false, + false, + false, + ), + ) + + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api + every { jellyfin.discovery } returns discovery + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Tests successfully entering and submitting a server URL + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_success() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = UUID.randomUUID().toString(), + startupWizardCompleted = true, + ), + ), + ), + ) + val quickConnectApi = mockk<QuickConnectApi>() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + switchServerViewModel.addServerState.value.let { + if (it is LoadingState.Error) throw it.exception ?: Exception(it.message) + } + +// coVerify(exactly = 1) { discovery.getRecommendedServers("localhost") } + Assert.assertEquals(1, setupNavigationManager.backStack.size) + Assert.assertTrue(setupNavigationManager.backStack.last() is SetupDestination.UserList) + } + + /** + * Tests entering and submitting a server URL that returns an error + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_error() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = null, // Invalid + startupWizardCompleted = false, + ), + ), + ), + ) + val quickConnectApi = mockk<QuickConnectApi>() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + Assert.assertTrue(switchServerViewModel.addServerState.value is LoadingState.Error) + + composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt new file mode 100644 index 00000000..39705dd3 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -0,0 +1,282 @@ +package com.github.damontecres.wholphin.ui + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile +import androidx.room.Room +import androidx.work.WorkManager +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.AppDatabase +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.Migrations +import com.github.damontecres.wholphin.data.PlaybackEffectDao +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.ServerPreferencesDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer +import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.services.hilt.AppModule +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.hilt.DatabaseModule +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher +import com.github.damontecres.wholphin.services.hilt.DeviceModule +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.services.hilt.IoDispatcher +import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory +import com.github.damontecres.wholphin.util.RememberTabManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.test.StandardTestDispatcher +import okhttp3.OkHttpClient +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.JellyfinOptions +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DeviceModule::class, AppModule::class], +) +object TestModule { + val testDispatcher = StandardTestDispatcher() + + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = DeviceInfo("test_device_id", "test_device") + + @Provides + @Singleton + fun clientInfo( + @ApplicationContext context: Context, + ): ClientInfo = + ClientInfo( + name = context.getString(R.string.app_name), + version = BuildConfig.VERSION_NAME, + ) + + @StandardOkHttpClient + @Provides + @Singleton + fun okHttpClient() = + OkHttpClient + .Builder() + .apply { + // TODO user agent, timeouts, logging, etc + }.build() + + @AuthOkHttpClient + @Provides + @Singleton + fun authOkHttpClient( + serverRepository: ServerRepository, + @StandardOkHttpClient okHttpClient: OkHttpClient, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ) = okHttpClient + .newBuilder() + .addInterceptor { + val request = it.request() + val newRequest = + serverRepository.currentUser.value?.accessToken?.let { token -> + request + .newBuilder() + .addHeader( + "Authorization", + AuthorizationHeaderBuilder.buildHeader( + clientName = clientInfo.name, + clientVersion = clientInfo.version, + deviceId = deviceInfo.id, + deviceName = deviceInfo.name, + accessToken = token, + ), + ).build() + } + it.proceed(newRequest ?: request) + }.build() + + @Provides + @Singleton + fun okHttpFactory( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) + + @Provides + @Singleton + fun jellyfin( + okHttpFactory: CoroutineContextApiClientFactory, + @ApplicationContext context: Context, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ): Jellyfin { + val jellyfin: Jellyfin = mockk() + every { jellyfin.clientInfo } returns clientInfo + every { jellyfin.deviceInfo } returns deviceInfo + every { jellyfin.options } returns + JellyfinOptions( + context, + clientInfo, + deviceInfo, + okHttpFactory, + okHttpFactory, + Jellyfin.minimumVersion, + ) + every { jellyfin.createApi(any()) } returns apiClient(jellyfin) + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns apiClient(jellyfin) + return jellyfin + } + + @Provides + @Singleton + fun apiClient(jellyfin: Jellyfin): ApiClient { + val api: ApiClient = mockk() + every { api.clientInfo } returns jellyfin.clientInfo!! + every { api.deviceInfo } returns jellyfin.deviceInfo!! + every { api.update(any(), any(), any(), any()) } returns Unit + return api + } + + /** + * Implementation of [RememberTabManager] which remembers by server, user, & item + */ + @Provides + @Singleton + fun rememberTabManager( + serverRepository: ServerRepository, + appPreference: DataStore<AppPreferences>, + @IoCoroutineScope scope: CoroutineScope, + ): RememberTabManager = mockk() + + @Provides + @Singleton + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @IoCoroutineScope + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @DefaultCoroutineScope + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + fun workManager( + @ApplicationContext context: Context, + ): WorkManager = mockk() + + @Provides + @Singleton + fun seerrApi( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ): SeerrApi = mockk() +} + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DatabaseModule::class], +) +object TestDatabaseModule { + @Module + @InstallIn(SingletonComponent::class) + object DatabaseModule { + @Provides + @Singleton + fun database( + @ApplicationContext context: Context, + ): AppDatabase = + Room + .inMemoryDatabaseBuilder( + context, + AppDatabase::class.java, + ).addMigrations(Migrations.Migrate2to3) + .allowMainThreadQueries() + .setQueryCallback({ sqlQuery, args -> + Timber.v("sqlQuery=$sqlQuery, args=$args") + }, Dispatchers.IO.asExecutor()) + .build() + + @Provides + @Singleton + fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao() + + @Provides + @Singleton + fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao() + + @Provides + @Singleton + fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao() + + @Provides + @Singleton + fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() + + @Provides + @Singleton + fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() + + @Provides + @Singleton + fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + + @Provides + @Singleton + fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + + @Provides + @Singleton + fun userPreferencesDataStore( + @ApplicationContext context: Context, + userPreferencesSerializer: AppPreferencesSerializer, + ): DataStore<AppPreferences> = + DataStoreFactory.create( + serializer = userPreferencesSerializer, + produceFile = { context.dataStoreFile("app_preferences.pb") }, + corruptionHandler = + ReplaceFileCorruptionHandler( + produceNewData = { AppPreferences.getDefaultInstance() }, + ), + ) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt new file mode 100644 index 00000000..d0bc8532 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt @@ -0,0 +1,16 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import org.jellyfin.sdk.api.client.Response + +@OptIn(ExperimentalTestApi::class) +fun SemanticsNodeInteraction.performClickEnter() = + performKeyInput { + pressKey(Key.DirectionCenter) + } + +fun <T> successResponse(content: T) = Response(content, 200, emptyMap()) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt new file mode 100644 index 00000000..6a6037f9 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt @@ -0,0 +1,53 @@ +package com.github.damontecres.wholphin.ui.playback + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SeekAccelerationTest { + @Test + fun returnsOneWhenNotRepeating() { + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 0, durationMs = 30_000L)) + } + + @Test + fun unknownDurationDoesNotAccelerate() { + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = 0L)) + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 300, durationMs = -1L)) + } + + @Test + fun shortContentHasTwoTiers() { + val shortDurationMs = 20L * 60_000L + + assertEquals( + 1, + calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = shortDurationMs), + ) + assertEquals( + 2, + calculateSeekAccelerationMultiplier(repeatCount = 90, durationMs = shortDurationMs), + ) + } + + @Test + fun mediumContentEscalatesAcrossAllTiers() { + val mediumDurationMs = 60L * 60_000L + + assertEquals( + 1, + calculateSeekAccelerationMultiplier(repeatCount = 38, durationMs = mediumDurationMs), + ) + assertEquals( + 2, + calculateSeekAccelerationMultiplier(repeatCount = 39, durationMs = mediumDurationMs), + ) + assertEquals( + 3, + calculateSeekAccelerationMultiplier(repeatCount = 150, durationMs = mediumDurationMs), + ) + assertEquals( + 4, + calculateSeekAccelerationMultiplier(repeatCount = 225, durationMs = mediumDurationMs), + ) + } +} 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 161791d5..7cba5860 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,22 +1,21 @@ [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" -kotlin = "2.3.0" -kotlinxCoroutinesCore = "1.10.2" -ksp = "2.3.0" +kache = "2.1.1" +kotlin = "2.3.10" +ksp = "2.3.6" coreKtx = "1.17.0" appcompat = "1.7.1" -composeBom = "2026.01.01" -mockk = "1.14.7" +composeBom = "2026.02.01" +mockk = "1.14.9" robolectric = "4.16.1" -multiplatformMarkdownRenderer = "0.39.1" +multiplatformMarkdownRenderer = "0.39.2" okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" @@ -24,18 +23,18 @@ timber = "5.0.1" tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.3" -androidx-media3 = "1.9.1" -coil = "3.3.0" +activityCompose = "1.12.4" +androidx-media3 = "1.9.2" +coil = "3.4.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.4" -hilt = "2.58" +protobuf-javalite = "4.34.0" +hilt = "2.59.2" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" @@ -43,6 +42,9 @@ workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" openapi-generator = "7.19.0" assMedia = "0.4.0-beta01" +kotlinxCoroutinesTest = "1.10.2" +coreTesting = "2.2.0" +runner = "1.7.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -68,9 +70,11 @@ androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-com androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } +androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } @@ -78,7 +82,9 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" } +kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" } 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" } @@ -98,6 +104,7 @@ androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } +androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } @@ -124,12 +131,14 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } ass-media = { group = "io.github.peerless2012", name = "ass-media", version.ref = "assMedia" } [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 e708b1c0..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 fbff848d..37f78a6a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Sat Sep 20 16:26:22 EDT 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c..adff685a 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or 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. @@ -15,81 +15,114 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f9..e509b2dd 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,33 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%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 -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..03e6a404 --- /dev/null +++ b/renovate.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "packageRules": [ + { + "groupName": "Dependencies", + "matchDepTypes": ["dependencies"], + "separateMultipleMajor": true + }, + { + "groupName": "Github Actions", + "matchManagers": ["github-actions"] + }, + { + "groupName": "Androidx Media3", + "matchPackageNames": [ + "androidx.media3:**" + ] + }, + { + "groupName": "Jellyfin SDK", + "matchPackageNames": [ + "org.jellyfin.sdk:**" + ] + }, + { + "groupName": "AGP", + "matchPackageNames": [ + "com.android.application**" + ] + }, + { + "groupName": "Kotlin", + "matchPackageNames": [ + "org.jetbrains.kotlin**" + ] + } + ] +}